Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/platform-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:
- "scripts/native-deps-smoke.mjs"
- "scripts/verify-libsql-vector.mjs"
- "scripts/smoke-test.mjs"
- "scripts/copy-web-assets.mjs"
- "web/**"
- ".github/workflows/platform-smoke.yml"
workflow_dispatch:

Expand Down Expand Up @@ -40,6 +40,10 @@ jobs:
- name: Install source dependencies
run: bun install

- name: Install web dependencies
working-directory: web
run: bun install

- name: Typecheck source
run: bun run typecheck

Expand Down
20 changes: 10 additions & 10 deletions SECURITY_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

**Scope:** `tickernelz/opencode-mem` @ commit `0998c69` (main). Local HTTP API, CORS policy, secret handling, SQLite layer, web UI, config/JSONC parsing, migration/cleanup services, dependencies. Read-only audit followed by targeted fixes for the three highest-severity findings.

**Note (GUI rewrite):** The Memory Explorer UI is now a Vite-bundled React 19 app under `web/` (served from `dist/web`). The previous CDN-loaded vanilla UI (`src/web/*`) and the interim Svelte UI are gone. Findings 3–4 below describe the pre-rewrite UI; current mitigations are noted under each.

## Findings

### 1. CRITICAL — Path traversal via unauthenticated `containerTag` (fixed)
Expand All @@ -16,21 +18,21 @@ A request such as `POST /api/memories {"content":"x","containerTag":"project_x_.

CORS was being used as a substitute for authentication in `src/services/cors.ts`, but `isAllowedBrowserOrigin()` returned `true` whenever no `Origin` header was present — which is the case for `curl`, other local processes, and any non-browser client. Every `/api/*` handler (read/write/delete memories, full user-profile CRUD, migrations) had no session/token check at all. If `webServerHost` is set to `0.0.0.0` (a documented config option), this is reachable from the whole LAN with no auth.

**Fix:** added `src/services/auth-token.ts` — a random 256-bit token generated on first run, persisted to `~/.opencode-mem/.auth-token` (mode `0600`), required via the `x-opencode-mem-token` header on every `/api/*` request in both `web-server.ts` and `web-server-worker.ts`. The token is injected into the server-rendered `index.html` (`window.__OPENCODE_MEM_TOKEN__`) so the bundled web UI keeps working transparently (`app.js:fetchAPI` now sends the header), while a malicious cross-origin web page cannot read it (opaque/no-cors responses). The internal `checkServerAvailable()` health-check call was updated to send the token too, so the existing takeover/health-check logic still works.
**Fix:** added `src/services/auth-token.ts` — a random 256-bit token generated on first run, persisted to `~/.opencode-mem/.auth-token` (mode `0600`), required via the `x-opencode-mem-token` header on every `/api/*` request in `web-server.ts`. The token is injected into the server-rendered `index.html` (`window.__OPENCODE_MEM_TOKEN__`) so the bundled web UI keeps working transparently (`web/src/lib/api.ts` sends the header), while a malicious cross-origin web page cannot read it (opaque/no-cors responses). The internal `checkServerAvailable()` health-check call was updated to send the token too, so the existing takeover/health-check logic still works.

This does not fully replace a "don't expose to `0.0.0.0` without more" warning — see recommendation below — but it closes the CSRF-style "any web page or generic local process can drive the API" gap the CORS check alone did not.

### 3. HIGH — Stored XSS via unescaped `profile.displayName` (fixed)
### 3. HIGH — Stored XSS via unescaped `profile.displayName` (fixed; superseded by React UI)

`renderUserProfile()` in `src/web/app.js` injected `profile.displayName` into `innerHTML` with no `escapeHtml()` call, unlike every other user-influenced field rendered elsewhere in the same file. `displayName` originates from `userNameOverride` in a project's `.opencode/opencode-mem.jsonc`, which is loaded automatically, with no confirmation, whenever that project is opened — so a malicious repo can plant a payload that later executes in the local Web UI when the profile tab is viewed, with same-origin `fetch()` access to the (now-authenticated, but still same-origin) API.
Historically, `renderUserProfile()` in the vanilla UI injected `profile.displayName` into `innerHTML` with no escaping. `displayName` originates from `userNameOverride` in a project's `.opencode/opencode-mem.jsonc`.

The existing `tests/web-memorytype-xss.test.ts` covers a different field (`memoryType`) that was already escaped correctly; it did not cover this sink.
**Current mitigation:** React JSX text nodes auto-escape; shared `escapeHtml()` in `web/src/lib/html.ts` covers any HTML string sinks. Markdown content goes through DOMPurify. Regression tests: `tests/web-userprofile-xss.test.ts`, `tests/web-memorytype-xss.test.ts`.

**Fix:** wrapped the value in `escapeHtml()`. Regression test: `tests/web-userprofile-xss.test.ts` (fails against the pre-fix code, passes after).
### 4. MEDIUM — Unpinned, non-SRI third-party scripts (fixed by bundling)

### 4. MEDIUM — Unpinned, non-SRI third-party scripts (not fixed — recommendation only)
Historically, the vanilla `index.html` loaded scripts from CDNs (`unpkg` / `jsdelivr`) without SRI.

`src/web/index.html` loads `lucide@latest` and `jsonrepair@latest` (no version pin) plus two pinned-but-no-`integrity` scripts from `unpkg.com`/`cdn.jsdelivr.net`. Recommend pinning exact versions and adding Subresource Integrity hashes, or self-hosting alongside the already-vendored `app.js`/`i18n.js`/`styles.css`.
**Current mitigation:** UI dependencies are installed and bundled by Vite into `dist/web` (no runtime CDN scripts).

### 5. LOW / informational — Gemini API key in URL query string (not fixed)

Expand All @@ -46,11 +48,9 @@ The existing `tests/web-memorytype-xss.test.ts` covers a different field (`memor

## Remaining recommendations (not implemented in this patch)

- Pin and add SRI to the CDN-loaded scripts in `src/web/index.html` (Finding 4).
- Consider warning (or refusing) at startup when `webServerHost` is set to a non-loopback address without additional network-level protection, since the token-based fix here raises the bar but a determined local/LAN attacker who can read `~/.opencode-mem/.auth-token` (or intercept the injected `<script>` in `index.html` over the LAN) still obtains it.
- Broader sweep of `src/web/app.js` for any other unescaped `innerHTML` sinks beyond the ones checked here, the same way `web-memorytype-xss.test.ts` covered `memoryType`.

## Verification performed

- `bun install && bunx tsc --noEmit` — no type errors introduced.
- `bun test` — all pre-existing passing tests remain green; the two new regression test files (7 test cases) pass against the patched code and were confirmed to fail against the pre-patch code. Unrelated failures in this sandbox (`EPERM` on `~/.opencode-mem/opencode-mem.log`, and `dist/plugin.js`-dependent tests requiring `bun run build`) are pre-existing environment conditions, not caused by this patch — verified by running the full suite on an unpatched `git stash` checkout, which showed the same failures.
- `bun test` — all pre-existing passing tests remain green; the two new regression test files pass against the patched code and were confirmed to fail against the pre-patch code. Unrelated failures in this sandbox (`EPERM` on `~/.opencode-mem/opencode-mem.log`, and `dist/plugin.js`-dependent tests requiring `bun run build`) are pre-existing environment conditions, not caused by this patch — verified by running the full suite on an unpatched `git stash` checkout, which showed the same failures.
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@
}
},
"scripts": {
"build": "rm -rf dist && bunx tsc && bun scripts/copy-web-assets.mjs",
"build": "rm -rf dist && bunx tsc && bun run web:build",
"web:dev": "cd web && bun run dev",
"web:build": "cd web && bun run build",
"dev": "tsc --watch",
"test": "bun test",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{ts,js,css,html}\"",
"format:check": "prettier --check \"src/**/*.{ts,js,css,html}\"",
"format": "prettier --write \"src/**/*.{ts,js,css,html}\" \"web/src/**/*.{ts,tsx,js,jsx,css,html}\"",
"format:check": "prettier --check \"src/**/*.{ts,js,css,html}\" \"web/src/**/*.{ts,tsx,js,jsx,css,html}\"",
"prepare": "husky"
},
"keywords": [
Expand Down Expand Up @@ -53,6 +55,7 @@
"@opencode-ai/plugin": "^1.18.4",
"@opencode-ai/sdk": "^1.18.4",
"franc-min": "^6.2.0",
"hono": "^4.12.32",
"iso-639-3": "^3.0.1",
"zod": "^4.4.3"
},
Expand Down
4 changes: 0 additions & 4 deletions scripts/copy-web-assets.mjs

This file was deleted.

102 changes: 82 additions & 20 deletions src/services/web-server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { join, dirname } from "node:path";
import { join, dirname, extname, normalize, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { log } from "./logger.js";
import { corsPreflightResponse, disallowedCorsResponse, isAllowedBrowserOrigin } from "./cors.js";
Expand Down Expand Up @@ -369,22 +369,21 @@ export class WebServer {
return this.serveStaticFile("index.html", "text/html");
}

if (path === "/styles.css") {
return this.serveStaticFile("styles.css", "text/css");
}

if (path === "/app.js") {
return this.serveStaticFile("app.js", "application/javascript");
}

if (path === "/i18n.js") {
return this.serveStaticFile("i18n.js", "application/javascript");
}

if (path === "/favicon.ico") {
return this.serveStaticFile("favicon.ico", "image/x-icon");
}

// Vite production assets (hashed JS/CSS/fonts) and other static files.
if (method === "GET" && !path.startsWith("/api/")) {
const relative = path.replace(/^\/+/, "");
if (relative && !relative.includes("..")) {
const staticResponse = this.serveStaticFile(relative, this.contentTypeFor(relative));
if (staticResponse.status !== 404) {
return staticResponse;
}
}
}

if (path === "/api/tags" && method === "GET") {
const result = await handleListTags();
return this.jsonResponse(result);
Expand Down Expand Up @@ -585,6 +584,11 @@ export class WebServer {
return this.jsonResponse(result);
}

// SPA fallback for client routes (e.g. /profile) — serve the app shell.
if (method === "GET" && !path.startsWith("/api/") && !extname(path)) {
return this.serveStaticFile("index.html", "text/html");
}

return new Response("Not Found", { status: 404 });
} catch (error) {
return this.jsonResponse(
Expand All @@ -597,24 +601,82 @@ export class WebServer {
}
}

private contentTypeFor(filename: string): string {
switch (extname(filename).toLowerCase()) {
case ".html":
return "text/html";
case ".js":
case ".mjs":
return "application/javascript";
case ".css":
return "text/css";
case ".ico":
return "image/x-icon";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".woff":
return "font/woff";
case ".woff2":
return "font/woff2";
case ".ttf":
return "font/ttf";
case ".json":
return "application/json";
case ".map":
return "application/json";
default:
return "application/octet-stream";
}
}

private serveStaticFile(filename: string, contentType: string): Response {
try {
const webDir = join(__dirname, "..", "web");
const filePath = join(webDir, filename);
const webDir = [
join(__dirname, "..", "web"),
join(__dirname, "..", "..", "dist", "web"),
].find((candidate) => existsSync(candidate));
if (!webDir) {
return new Response("File not found", { status: 404 });
}

if (contentType.startsWith("image/")) {
const filePath = normalize(join(webDir, filename));
const normalizedWebDir = normalize(webDir);
if (filePath !== normalizedWebDir && !filePath.startsWith(`${normalizedWebDir}${sep}`)) {
return new Response("File not found", { status: 404 });
}
if (!existsSync(filePath)) {
return new Response("File not found", { status: 404 });
}

const cacheControl =
filename.startsWith("assets/") || contentType.startsWith("font/")
? "public, max-age=31536000, immutable"
: contentType.startsWith("image/")
? "public, max-age=86400"
: "no-cache";

if (
contentType.startsWith("image/") ||
contentType.startsWith("font/") ||
contentType === "application/octet-stream"
) {
const content = readFileSync(filePath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
"Cache-Control": cacheControl,
},
});
}

let content = readFileSync(filePath, "utf-8");

if (filename === "index.html") {
if (filename === "index.html" || filename.endsWith("/index.html")) {
const token = getOrCreateAuthToken();
content = content.replace(
"</head>",
Expand All @@ -625,7 +687,7 @@ export class WebServer {
return new Response(content, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-cache",
"Cache-Control": cacheControl,
},
});
} catch (error) {
Expand Down
36 changes: 36 additions & 0 deletions src/shared/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export {
AddMemoryRequestSchema,
AICleanupRequestSchema,
ApiResultSchema,
ApplyCleanupRequestSchema,
BulkIdsRequestSchema,
CleanupChangesSchema,
MemoryItemSchema,
PaginatedMemoriesSchema,
PendingCleanupSchema,
ProfileDataSchema,
ProfileItemSchema,
TagInfoSchema,
UpdateMemoryRequestSchema,
UpdateProfileItemRequestSchema,
UserProfileSchema,
} from "./schemas.js";

export type {
AddMemoryRequest,
AICleanupRequest,
ApiResult,
ApplyCleanupRequest,
BulkIdsRequest,
CleanupChanges,
MemoryGroup,
MemoryItem,
PaginatedMemories,
PendingCleanup,
ProfileData,
ProfileItem,
TagInfo,
UpdateMemoryRequest,
UpdateProfileItemRequest,
UserProfile,
} from "./types.js";
Loading