diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01b1ce2..d2d7239 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.4.0" - run: bun install --frozen-lockfile diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 05320db..4d632db 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -26,9 +26,9 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.4.0" - - run: bun install + - run: bun install --frozen-lockfile - name: Build run: bun run build diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fdd1aca..3426c42 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,8 +18,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Warmup timeout race no longer triggers an unhandled promise rejection.** - **Auto-capture and profile learning now wait for opencode provider state instead of racing it at startup.** - **The forget tool now reports actual deletion failures instead of always claiming success.** -### Fixed - - **Re-embed migrations now update vectors in place, and migration operations report success only when every shard succeeded — failures propagate to the admin UI instead of being logged away.** - **Exact-duplicate cleanup is now transactional (no partial purges on crash) and no longer writes memory content into host logs.** - **Memory archival now commits its sqlite transaction before touching the vector index — no more async work inside BEGIN IMMEDIATE, and archived ids are reliably deleted from the index.** @@ -28,6 +26,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **SQLite write transactions are no longer held open across async vector-index updates — eliminating nested-transaction/SQLITE_BUSY risks under concurrent captures, decay, and admin operations.** - **Keyword search and FTS boost now work: the memories_fts FTS5 virtual table (absent since shards were created without it) is created on new and existing shards — keyword search no longer silently degrades to a full-table LIKE scan.** - **Decay cycle now rotates through all decayable memories (ordered by last_decay_at) instead of repeatedly processing only the first batch; rows past the batch cap now decay and archive.** +- CI and SonarCloud workflows pin Bun to 1.4.0 and install with --frozen-lockfile for reproducible gates. + +### Security + +- Dashboard requests are now rejected unless the Host header is loopback or explicitly configured — closes a DNS-rebinding route to the unauthenticated local API. +- Bun.serve now applies the same 256 KiB request body cap as the Node adapter, preventing memory-exhaustion via oversized dashboard payloads. +- Web dashboard loads app.js after DOMPurify and sanitizes fail-closed when the sanitizer is unavailable. +- Transcript keyword search now sanitizes FTS5 operator syntax from user queries, matching the memory search path. +- API key comparison is now constant-time without a length short-circuit. ## [2.23.1] - 2026-09-07 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ed18b61..e03350a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -146,6 +146,8 @@ Auto-capture observes chat exchanges and automatically extracts memorable inform | `webServerHost` | `string` | `"127.0.0.1"` | Host binding for the web server. Defaults to loopback for security. **`webServerApiKey` is required if binding to a non-loopback address.** | | `webServerApiKey` | `string` | — | API key for authenticating web UI requests. Required when `webServerHost` is not a loopback address (`127.0.0.1`, `localhost`, `::1`). Value is used as-is (no secret resolution). | +Requests are accepted only when the `Host` header is loopback (`127.0.0.1`, `localhost`, `[::1]`) or the configured `webServerHost` (hostname compared case-insensitively, port ignored). If you reverse-proxy the dashboard, set `webServerHost` to the proxy hostname. + ## Vector Search Settings | Setting | Type | Default | Description | diff --git a/src/services/platform-server.ts b/src/services/platform-server.ts index a7e21aa..6b89da2 100644 --- a/src/services/platform-server.ts +++ b/src/services/platform-server.ts @@ -29,6 +29,8 @@ function normalizeHeaders(rawHeaders: IncomingMessage["headers"]): Headers { const kRemoteAddress = Symbol.for("opencode-mem0.remoteAddress"); type RequestWithIP = Request & { [kRemoteAddress]?: string }; +const MAX_BODY_BYTES = 262_144; // 256 KiB for JSON API payloads + function createNodeServer(options: ServeOptions): Promise { // skipcq: JS-0323 — reuseAddr option is needed for Windows port reuse but not in ServerOptions type const nodeServer = createServer( @@ -38,7 +40,6 @@ function createNodeServer(options: ServeOptions): Promise { const host = req.headers.host || `${options.hostname}:${options.port}`; const url = `http://${host}${req.url}`; - const MAX_BODY_BYTES = 262_144; // 256 KiB for JSON API payloads const chunks: Buffer[] = []; let totalBytes = 0; for await (const chunk of req) { @@ -107,7 +108,10 @@ function createNodeServer(options: ServeOptions): Promise { export function serve(options: ServeOptions): Promise { if (globalThis.Bun !== undefined && globalThis.Bun.serve) { - const bunServer = globalThis.Bun.serve(options); + const bunServer = globalThis.Bun.serve({ + ...options, + maxRequestBodySize: MAX_BODY_BYTES, + }); return Promise.resolve({ stop: () => bunServer.stop(), requestIP: (req: Request) => bunServer.requestIP(req), diff --git a/src/services/sqlite/transcript-manager.ts b/src/services/sqlite/transcript-manager.ts index a1edc61..d33dff6 100644 --- a/src/services/sqlite/transcript-manager.ts +++ b/src/services/sqlite/transcript-manager.ts @@ -175,6 +175,13 @@ export class TranscriptManager { ): { transcripts: TranscriptRecord[]; total: number } { if (!CONFIG.transcriptStorage.enabled) return { transcripts: [], total: 0 }; + const safeFtsQuery = query + .replace(/[*^:\-+?()"]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 500); + if (safeFtsQuery.length === 0) return { transcripts: [], total: 0 }; + try { const db = this.getDb(); @@ -184,12 +191,12 @@ export class TranscriptManager { SELECT count(*) as total FROM transcripts_fts WHERE transcripts_fts MATCH ? ` ) - .get(query) as { total: number } | null; + .get(safeFtsQuery) as { total: number } | null; const rows = db .prepare( ` - SELECT t.${TRANSCRIPT_FIELDS} + SELECT t.id, t.session_id, t.project_path, t.messages, t.created_at, t.token_count FROM transcripts t JOIN transcripts_fts fts ON fts.rowid = t.rowid WHERE transcripts_fts MATCH ? @@ -197,7 +204,7 @@ export class TranscriptManager { LIMIT ? OFFSET ? ` ) - .all(query, limit, offset) as any[]; + .all(safeFtsQuery, limit, offset) as any[]; return { transcripts: rows.map(rowToTranscript), diff --git a/src/services/web-server.ts b/src/services/web-server.ts index a2d265e..151ebe0 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { randomInt, timingSafeEqual } from "node:crypto"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { log } from "./logger.js"; +import { log, warn } from "./logger.js"; import { serve, type PlatformServer } from "./platform-server.js"; import { CONFIG, isConfigured } from "../config.js"; import type { UserProfileData } from "./user-profile/types.js"; @@ -80,6 +80,37 @@ function isLoopbackHost(host: string): boolean { return LOCAL_HOSTS.has(host.trim().toLowerCase()); } +function hostnameFromHostHeader(raw: string): string { + const host = raw.trim().toLowerCase(); + if (host.startsWith("[")) { + const end = host.indexOf("]"); + if (end !== -1) return host.slice(1, end); + } + const colon = host.lastIndexOf(":"); + if (colon > 0 && /^\d+$/.test(host.slice(colon + 1))) { + return host.slice(0, colon); + } + return host; +} + +function isHostAllowed(headers: Headers, config: WebServerConfig): boolean { + const raw = headers.get("host"); + if (!raw) return false; + const hostname = hostnameFromHostHeader(raw); + const allowed = new Set(["127.0.0.1", "localhost", "::1"]); + const configured = hostnameFromHostHeader(config.host); + if (configured) allowed.add(configured); + if (allowed.has(hostname)) return true; + const rawLower = raw.trim().toLowerCase(); + if (config.port !== 80 && config.port !== 443) { + for (const h of allowed) { + if (rawLower === `${h}:${config.port}`) return true; + if (h.includes(":") && rawLower === `[${h}]:${config.port}`) return true; + } + } + return false; +} + export class WebServer { private server: PlatformServer | null = null; private readonly config: WebServerConfig; @@ -260,6 +291,13 @@ export class WebServer { } private async handleRequest(req: Request): Promise { + if (!isHostAllowed(req.headers, this.config)) { + warn("Rejected request with disallowed Host header", { + host: req.headers.get("host"), + }); + return this.jsonResponse({ success: false, error: "Forbidden" }, 403); + } + let url: URL; try { url = new URL(req.url); @@ -327,14 +365,18 @@ export class WebServer { if (!apiKey) return false; const headerKey = req.headers.get("x-opencode-mem-key") ?? ""; const bearerKey = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/, ""); - const keyBuf = Buffer.from(apiKey); - // Compare both possible header sources in constant time - const hBuf = Buffer.from(headerKey.padEnd(apiKey.length, "\0").slice(0, apiKey.length)); - const bBuf = Buffer.from(bearerKey.padEnd(apiKey.length, "\0").slice(0, apiKey.length)); - return ( - (headerKey.length === apiKey.length && timingSafeEqual(keyBuf, hBuf)) || - (bearerKey.length === apiKey.length && timingSafeEqual(keyBuf, bBuf)) - ); + // Compare on fixed-size padded buffers: constant 32-byte inputs mean the + // comparison time never depends on secret length, and the secret is never + // hashed. Truncation only matters beyond 32 bytes (~256-bit keys). + const pad32 = (secret: string) => { + const buf = Buffer.alloc(32); + Buffer.from(secret, "utf8").copy(buf, 0, 0, 32); + return buf; + }; + const expected = pad32(apiKey); + const headerOk = timingSafeEqual(pad32(headerKey), expected); + const bearerOk = timingSafeEqual(pad32(bearerKey), expected); + return headerOk || bearerOk; } private async _dispatchApiRoute( diff --git a/src/web/app.js b/src/web/app.js index 3585975..98f821b 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -154,11 +154,10 @@ function itemTypeBadge(item) { * Final barrier for every HTML string that hits a DOM sink. Per-value esc() * plus DOMPurify for markdown, already covers interpolation; sanitizing again * at the sink is defense-in-depth (and what CodeQL recognizes as an XSS - * barrier). Falls back to the raw string when DOMPurify hasn't loaded - * (e.g. unit smoke harness without vendor scripts). + * barrier). Falls back to escaped text when DOMPurify hasn't loaded. */ function sanitizeHtml(html) { - return window.DOMPurify ? DOMPurify.sanitize(html) : html; + return window.DOMPurify ? DOMPurify.sanitize(html) : esc(html); } // ── API client ───────────────────────────────────────────────────────────── diff --git a/src/web/index.html b/src/web/index.html index 64837ad..c66502d 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -16,6 +16,6 @@
- + diff --git a/tests/transcript-fts-sanitize.test.ts b/tests/transcript-fts-sanitize.test.ts new file mode 100644 index 0000000..be9f124 --- /dev/null +++ b/tests/transcript-fts-sanitize.test.ts @@ -0,0 +1,41 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CONFIG } from "../src/config.js"; +import { TranscriptManager } from "../src/services/sqlite/transcript-manager.js"; + +describe("transcript FTS query sanitization", () => { + let tmp: string; + let originalPath: string; + let originalEnabled: boolean; + let mgr: TranscriptManager; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "opencode-mem0-fts-")); + originalPath = CONFIG.storagePath; + originalEnabled = CONFIG.transcriptStorage.enabled; + CONFIG.storagePath = tmp; + CONFIG.transcriptStorage.enabled = true; + mgr = new TranscriptManager(); + mgr.saveTranscript("sess-1", "/p", [{ role: "user", content: "unclosed quote about react" }]); + }); + + afterEach(() => { + mgr.close(); + CONFIG.storagePath = originalPath; + CONFIG.transcriptStorage.enabled = originalEnabled; + rmSync(tmp, { recursive: true, force: true }); + }); + + it("does not throw on FTS operator syntax and still matches tokens", () => { + expect(() => mgr.searchTranscripts(`"unclosed quote`)).not.toThrow(); + const quoted = mgr.searchTranscripts(`"unclosed quote`); + expect(quoted.transcripts.length).toBeGreaterThan(0); + + expect(() => mgr.searchTranscripts(`" ( ay "*`)).not.toThrow(); + const junk = mgr.searchTranscripts(`" ( ay "*`); + expect(junk.transcripts).toEqual([]); + expect(junk.total).toBe(0); + }); +}); diff --git a/tests/web-dashboard-sanitize.test.ts b/tests/web-dashboard-sanitize.test.ts new file mode 100644 index 0000000..c2879de --- /dev/null +++ b/tests/web-dashboard-sanitize.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const html = readFileSync(new URL("../src/web/index.html", import.meta.url), "utf8"); +const appJs = readFileSync(new URL("../src/web/app.js", import.meta.url), "utf8"); + +describe("web dashboard sanitizer loading", () => { + it("defers app.js after DOMPurify", () => { + expect(html).toContain(''); + expect(html).toContain(''); + expect(html.indexOf("/vendor/dompurify.min.js")).toBeLessThan(html.indexOf('src="/app.js"')); + }); + + it("sanitizeHtml fails closed when DOMPurify is missing", () => { + expect(appJs).toContain("window.DOMPurify ? DOMPurify.sanitize(html) : esc(html)"); + expect(appJs).not.toMatch(/DOMPurify\.sanitize\(html\) : html/); + }); +}); diff --git a/tests/web-server-routes.test.ts b/tests/web-server-routes.test.ts index e1b79be..bd11fad 100644 --- a/tests/web-server-routes.test.ts +++ b/tests/web-server-routes.test.ts @@ -9,6 +9,7 @@ global.fetch = mockFetch as unknown as typeof fetch; // Mock logger vi.mock("../src/services/logger.js", () => ({ log: vi.fn(), + warn: vi.fn(), })); // Mock dependencies @@ -107,7 +108,7 @@ describe("WebServer Routes", () => { await server.start(); const fetchHandler = (serve as any).mock.calls[0][0].fetch; - const headers: Record = {}; + const headers: Record = { host: "127.0.0.1:18080" }; if (apiKey) headers["x-opencode-mem-key"] = apiKey; const req = new Request(`http://127.0.0.1:18080${path}`, { @@ -134,6 +135,19 @@ describe("WebServer Routes", () => { expect(json.success).toBe(false); }); + it("rejects a wrong-length API key with 401", async () => { + server = new WebServer({ + port: 18081, + host: "127.0.0.1", + enabled: true, + apiKey: "secret123", + }); + (serve as any).mockResolvedValue(mockPlatformServer); + + const res = await makeRequest("/api/tags", "GET", undefined, "x"); + expect(res.status).toBe(401); + }); + it("allows requests with correct API key", async () => { server = new WebServer({ port: 18082, @@ -149,6 +163,48 @@ describe("WebServer Routes", () => { }); }); + describe("Host header allowlist", () => { + it("rejects Host: evil.example.com with 403", async () => { + await server.start(); + const fetchHandler = (serve as any).mock.calls[0][0].fetch; + const res = await fetchHandler( + new Request("http://evil.example.com/api/health", { + headers: { host: "evil.example.com" }, + }) + ); + expect(res.status).toBe(403); + }); + + it("allows Host: localhost:4747", async () => { + await server.start(); + const fetchHandler = (serve as any).mock.calls[0][0].fetch; + const res = await fetchHandler( + new Request("http://localhost:4747/api/health", { + headers: { host: "localhost:4747" }, + }) + ); + expect(res.status).toBe(200); + }); + + it("allows the configured non-loopback host", async () => { + server = new WebServer({ + port: 18080, + host: "192.168.1.10", + enabled: true, + apiKey: "secret123", + }); + (serve as any).mockResolvedValue(mockPlatformServer); + await server.start(); + const fetchHandler = (serve as any).mock.calls[0][0].fetch; + const res = await fetchHandler( + new Request("http://192.168.1.10:18080/api/health", { + headers: { host: "192.168.1.10:18080" }, + }) + ); + expect(res.status).toBe(200); + }); + }); + describe("Static Files", () => { it("serves index.html at root", async () => { const res = await makeRequest("/"); @@ -507,6 +563,7 @@ describe("WebServer Routes", () => { const fetchHandler = (serve as any).mock.calls[0][0].fetch; const req = new Request("http://127.0.0.1:18080/api/config", { method: "PUT", + headers: { host: "127.0.0.1:18080" }, body: "{not valid json", }); const res = await fetchHandler(req);