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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 6 additions & 2 deletions src/services/platform-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlatformServer> {
// skipcq: JS-0323 — reuseAddr option is needed for Windows port reuse but not in ServerOptions type
const nodeServer = createServer(
Expand All @@ -38,7 +40,6 @@ function createNodeServer(options: ServeOptions): Promise<PlatformServer> {
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) {
Expand Down Expand Up @@ -107,7 +108,10 @@ function createNodeServer(options: ServeOptions): Promise<PlatformServer> {

export function serve(options: ServeOptions): Promise<PlatformServer> {
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),
Expand Down
13 changes: 10 additions & 3 deletions src/services/sqlite/transcript-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +178 to +182

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Punctuated transcript searches return empty

Queries like react.js, don't, and email@example.com survive safeFtsQuery with invalid FTS5 syntax. The caught database error returns no matches, even when transcripts contain those terms.

Prompt for agents
TranscriptManager.searchTranscripts in src/services/sqlite/transcript-manager.ts passes partially sanitized bare text to FTS5 MATCH. FTS5 rejects many punctuation characters not covered by the current regex, including periods, apostrophes, slashes, at-signs, brackets, exclamation marks, and ampersands. Reserved standalone tokens such as AND, OR, and NOT also remain operators or produce syntax errors. Convert user input into a valid literal-token FTS query, or add a reliable fallback that still searches the original terms. Add regression cases for common punctuated terms and reserved words.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #66 (146e4a0): tokens are phrase-quoted ("don't") in both the memory (searchFTS5) and transcript paths, so punctuation stays matchable and reserved words become valid empty phrase queries. tests/transcript-fts-sanitize.test.ts covers don't / react.js / email@x / AND.

if (safeFtsQuery.length === 0) return { transcripts: [], total: 0 };

try {
const db = this.getDb();

Expand All @@ -184,20 +191,20 @@ 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 ?
ORDER BY rank
LIMIT ? OFFSET ?
`
)
.all(query, limit, offset) as any[];
.all(safeFtsQuery, limit, offset) as any[];

return {
transcripts: rows.map(rowToTranscript),
Expand Down
60 changes: 51 additions & 9 deletions src/services/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Comment on lines +83 to +94

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially fixed in #66 (146e4a0): unbracketed multi-colon Host strings now parse as whole hostnames. The "configured IPv6 hosts rejected" half of the claim is covered by the IPv6 test there.


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);
Comment on lines +101 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Separate allowed Host names from the bind address

A valid remote deployment that binds with webServerHost: "0.0.0.0" now rejects every client request: browsers connecting through a LAN IP or DNS name send that address in Host, but this set permits only 0.0.0.0 and loopback names. The same regression occurs with a normal reverse proxy that preserves its public Host while the backend remains bound to 127.0.0.1; changing webServerHost to the public name also changes the value passed to serve as the listening hostname. Keep bind-address configuration separate from the accepted external host names (or explicitly support wildcard bind addresses), otherwise the dashboard and API are inaccessible in these deployments.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — this was the breaking one. Fixed in #66 (146e4a0): new webServerAllowedHosts: string[] config keeps bind semantics (webServerHost) separate from accepted Host names, and docs/CONFIGURATION.md now documents binding remotely / behind a proxy correctly (the old guidance did change the listen interface, as you noted).

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;
Expand Down Expand Up @@ -260,6 +291,13 @@ export class WebServer {
}

private async handleRequest(req: Request): Promise<Response> {
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);
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 2 additions & 3 deletions src/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion src/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
<div id="app"></div>
<div id="modal-root"></div>
<div id="toast-root"></div>
<script src="/app.js"></script>
<script defer src="/app.js"></script>
</body>
</html>
41 changes: 41 additions & 0 deletions tests/transcript-fts-sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions tests/web-dashboard-sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -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('<script defer src="/vendor/dompurify.min.js"></script>');
expect(html).toContain('<script defer src="/app.js"></script>');
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/);
});
});
59 changes: 58 additions & 1 deletion tests/web-server-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,7 +108,7 @@ describe("WebServer Routes", () => {
await server.start();
const fetchHandler = (serve as any).mock.calls[0][0].fetch;

const headers: Record<string, string> = {};
const headers: Record<string, string> = { 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}`, {
Expand All @@ -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,
Expand All @@ -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("/");
Expand Down Expand Up @@ -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);
Expand Down
Loading