Request an audit

// write-up

When null is a user: private memory disclosure in AnythingLLM.

An anonymous embed visitor received the instance owner's private Memories. Not prompt injection: a missing identity that collapsed into the owner's own storage scope. GHSA-8wh8-3v68-whwj, fixed under eleven hours after the report.

Published
Reading time
6 min
A glowing chat bubble rising from an open book: private memories surfacing in a public chat

Most write-ups about AI application security are about prompt injection. This one is not. The bug behind GHSA-8wh8-3v68-whwj is older and plainer than anything model-specific: a missing identity that degrades into a permissive default instead of a denial. The model never misbehaved. It was handed the owner’s private notes and asked to be helpful.

The target

AnythingLLM (Mintplex-Labs) is a self-hosted application for running chat over your own documents. Two of its features matter here. Memories, part of Personalization, lets the instance remember facts about you across conversations and injects them into the system prompt. Embed widgets let you publish a workspace as a chat box on a public website, reachable by anyone, with no login by design. An embed can carry an origin allowlist, but it is checked against the request’s own Origin header, so it does not make the endpoint authenticated.

Those two features are supposed to live in different worlds. The audit ran at master HEAD 3a85d3e7, 56 commits past the v1.16.1 release (35c58d89). No live instance was run at any point: this is static analysis of a pinned commit, backed by file and line references and by a standalone reproduction of the decisive expression.

Three lines, three files

The embed handler takes the visitor’s self-declared username, a free string typed into the widget, and passes it into the chat prompt builder in the slot where an authenticated user object belongs (server/utils/chats/embed.js:182). It is a string, not a user record, and nothing between the two checks that. The string is not even load-bearing: it defaults to null when the widget sends none (server/endpoints/embed/index.js:32), and the lookup lands on the same scope either way.

Downstream the prompt builder reads the id off it: userId: user?.id ?? null (server/utils/chats/index.js:126). A string has no id property, so the optional chain yields undefined, and the coalescing turns that into null. The intent behind the line is obvious and reasonable: if there is no user, there is no id, so do not throw.

The third line is where the intent breaks. Memory lookup scopes by user id (server/models/memory.js:73, predicate at :77), and in single-user mode the owner’s own memories are written under precisely that scope: null (server/endpoints/memory.js:85 into server/models/memory.js:118). Here null is not the absence of a user. It is the identity of the only user.

So the chain resolves cleanly, with no error raised anywhere along it. An anonymous visitor arrives, there is no user, “no user” becomes null, and null is the owner. The owner’s private memories are appended to the system prompt of that conversation under the heading ## Things I Remember About You (server/utils/memories/index.js:103-109), on a route that has no identity check, because it is a public widget and was never meant to need one.

Confirming it was not a read-side artifact

A read path landing on null proves little on its own. If the write path stored the owner’s memories under some other scope, the read would simply come back empty and there would be no finding. So the write side had to be settled before the report went out.

Both production write paths, the owner saving a memory by hand and the automatic extraction job, normalize through the same Memory.validations.userId (server/models/memory.js:30) as the read path, and both land on null in single-user mode. The column is nullable with no default and no sentinel (server/prisma/schema.prisma:433), and there is no separate system scope, the only scopes are workspace and global. Prisma persists SQL NULL on write and emits WHERE user_id IS NULL on read. The owner’s memories are genuinely written under null and read back under null, by the same normalizer, which is what turns a suspicious code path into a finding. A fourth memories.create call site exists in the tree but is test-only, with no production caller.

The proof was a standalone script that imports nothing and needs no install, so the vendor could check the convergence without building the application. Whether a given model then recites the injected block back to the visitor was deliberately not claimed: that is ordinary model behaviour and varies by provider and by any operator prompt override. The vulnerability is the data entering the anonymous context, and that part is complete server-side.

Preconditions, stated plainly

The issue needs all four of these at once:

  • the instance running in single-user mode, which is the default self-hosted posture
  • Personalization enabled, which is off by default (server/models/systemSettings.js:760-768)
  • at least one memory already stored, since nothing is injected when both sets are empty. In practice this fills itself: automatic extraction defaults on once Personalization is enabled (server/models/systemSettings.js:770)
  • at least one published embed widget

That list narrows the affected population considerably, and it belongs in the report rather than in a footnote. A finding that survives its own preconditions being stated honestly is worth more than one that needs them hidden.

What it scored

Moderate, 5.3, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N, CWE-668. Network reachable, no privileges, no user interaction, but the confidentiality impact is bounded by whatever the owner chose to put into Memories, and there is no integrity or availability effect at all.

Disclosure

Reported 14 September 2026 as a private GitHub Security Advisory. The advisory was published as GHSA-8wh8-3v68-whwj ten and a half hours later, and the fix commit landed about a minute after that. Affected versions >= 1.13.0 <= 1.16.1, patched in commit 08ff2c73, which as of 21 September 2026 is on master and not yet in a tagged release. Credit accepted as 0xiviel / Eva Crystal (XSource Security).

Ten and a half hours is a vendor response time, not an audit duration. It says something about how Mintplex Labs handles reports and nothing about how long the work took.

The pattern worth keeping

Optional chaining is not an authorization primitive. user?.id answers the question “might this be missing”, and it answers it safely. It does not answer the question that actually matters, which is what the missing value means to everything downstream of it.

In a multi-tenant or multi-user system, that downstream meaning is usually the interesting part. Anywhere a scope, tenant or owner lookup is keyed on a value that can be null or undefined, the question to ask is not “can this be null” but “what does null resolve to in the datastore”. If the answer is the first user, the system scope, or the single-user install’s owner, then the permissive default is the most privileged identity in the product.

This is worth grepping for deliberately rather than hoping to notice. The bug class does not announce itself: there is no crash, no error log, no failed check to trip an alert. Everything succeeds, and the wrong person’s data ends up in the context the model is answering from.

Disclosure record

GHSA-8wh8-3v68-whwj
AnythingLLM: owner's private Memories disclosed to anonymous embed-widget visitors

Record page →

Same scrutiny, your code

Send the repository or product, the component you care about most, the build under review and the window you have in mind. A written scope and a fixed price come back before any work begins.

Request an audit →

← All write-ups

More write-ups

All write-ups →

Inside the Lenovo SMM escalation chain

Part 1 of 4. The Lenovo WmiBiosSetupSmm disclosure (CVE-2026-10587 to -10590), coordinated with Lenovo PSIRT under case LEN-213632 and published in advisory LEN-220440 on 14 July 2026. Most vulnerability write-ups live in the operating system, the…

Read write-up →

Validating the unvalidated: SMI input handling

Part 2 of 4: the Lenovo WmiBiosSetupSmm disclosure. Start with Part 1 for the architecture and the escalation chain. Part 1 named the target: WmiBiosSetupSmm, software SMI 0xDE, on the Lenovo Legion 5 15IRX10 (BIOS QNCN33WW v1.33, InsydeH2O). This part opens the handler and…

Read write-up →