diff --git a/README.md b/README.md index de34c23..d4051e9 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,31 @@ The plugin creates a full commented template at this path on first startup. This - `scope: "all-projects"`: query `search` / `list` across all project shards. - `memory.defaultScope` sets the default query scope when no explicit scope is provided. +### Web UI HTTP Basic Auth + +When `webServerHost` is set to anything other than loopback (for example `0.0.0.0`), the web UI is reachable by anyone on the network. To keep your memories off the LAN, gate the web server with HTTP Basic Auth via the same config file used for everything else: + +```jsonc +{ + "webServerHost": "0.0.0.0", // optional: reach the UI from the LAN + "webServerAuthPassword": "pick-a-strong-one", + "webServerAuthUsername": "admin", // optional, defaults to the current OS user +} +``` + +| Field | Default | Effect | +| ----------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `webServerAuthPassword` | _(empty)_ | When set, the server demands HTTP Basic Auth credentials on every request. Leave empty to keep the open-by-default behavior. | +| `webServerAuthUsername` | OS user (`$USER`) | Username required by the Basic Auth challenge. | + +`webServerAuthPassword` accepts the same secret formats as `memoryApiKey`: + +- a literal string (simple, fine for personal machines), +- `env://SOME_ENV_VAR` to pull the value from the environment at startup, +- `file:///path/to/secret` to read it from a file (`chmod 600` recommended — the plugin will warn if the file is world-readable). + +The browser will pop its native Basic Auth dialog and remember the credentials for the current session; closing all browser windows discards them, so reopening the browser requires signing in again. Credentials are compared with a constant-time check, and the unauthenticated 401 response carries `Cache-Control: no-store` so no intermediate cache will replay it. CORS is also relaxed once auth is on, so other tools on the same LAN can talk to the API after authenticating. + ### Sharing One Project Memory Across Nested Repos By default a project is identified by its enclosing git repository, so every diff --git a/scripts/test-auth-server.ts b/scripts/test-auth-server.ts new file mode 100644 index 0000000..7d545dd --- /dev/null +++ b/scripts/test-auth-server.ts @@ -0,0 +1,52 @@ +import { WebServer } from "../src/services/web-server.js"; +import { WebAuth } from "../src/services/web-auth.js"; +import { resolveSecretValue } from "../src/services/secret-resolver.js"; + +const PORT = Number(process.env.PORT ?? 14747); +const HOST = process.env.HOST ?? "127.0.0.1"; + +// Mirrors how src/index.ts plumbs the opencode-mem config through to WebAuth: +// both values are optional in the config file, and `webServerAuthPassword` +// accepts the same env:// / file:// shorthand as `memoryApiKey`. +const password = resolveSecretValue(process.env.WEB_AUTH_PASSWORD); +const username = process.env.WEB_AUTH_USERNAME ?? ""; + +const auth = new WebAuth({ password, username }); +const server = new WebServer({ + port: PORT, + host: HOST, + enabled: true, + auth, +}); + +await server.start(); + +const url = server.getUrl(); +const config = auth.getConfig(); +const enabledTag = config.enabled ? "ENABLED" : "DISABLED"; + +console.log("============================================================"); +console.log(" OpenCode Memory Explorer — auth test harness"); +console.log("============================================================"); +console.log(` URL : ${url}`); +console.log(` Auth : ${enabledTag}`); +if (config.enabled) { + console.log(` Username : ${config.username}`); + console.log(` Password : (see WEB_AUTH_PASSWORD env var —`); + console.log(` supports literal, env://, file://)`); +} +console.log("------------------------------------------------------------"); +console.log(" Try these in a fresh browser window:"); +console.log(` 1. Open ${url} → should pop the Basic Auth dialog`); +console.log(` 2. Cancel → 401 page → auth failed`); +console.log(` 3. Wrong password → 401 page → auth failed`); +console.log(` 4. Right password → app loads → auth ok`); +console.log(" 5. /api/health includes authEnabled flag for the warning banner"); +console.log("------------------------------------------------------------"); +console.log(" Press Ctrl-C to stop."); + +process.on("SIGINT", async () => { + console.log("\nStopping…"); + await server.stop(); + process.exit(0); +}); diff --git a/src/config.ts b/src/config.ts index 79153de..64c497f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -54,6 +54,8 @@ interface OpenCodeMemConfig { webServerEnabled?: boolean; webServerPort?: number; webServerHost?: string; + webServerAuthPassword?: string; + webServerAuthUsername?: string; maxVectorsPerShard?: number; autoCleanupEnabled?: boolean; autoCleanupRetentionDays?: number; @@ -109,6 +111,8 @@ const DEFAULTS: Required< | "autoCaptureLanguage" | "userEmailOverride" | "userNameOverride" + | "webServerAuthPassword" + | "webServerAuthUsername" > > & { embeddingApiUrl?: string; @@ -125,6 +129,8 @@ const DEFAULTS: Required< autoCaptureLanguage?: string; userEmailOverride?: string; userNameOverride?: string; + webServerAuthPassword?: string; + webServerAuthUsername?: string; memory?: { defaultScope?: "project" | "all-projects"; }; @@ -146,6 +152,8 @@ const DEFAULTS: Required< webServerEnabled: true, webServerPort: 4747, webServerHost: "127.0.0.1", + webServerAuthPassword: undefined, + webServerAuthUsername: undefined, maxVectorsPerShard: 50000, autoCleanupEnabled: true, autoCleanupRetentionDays: 30, @@ -255,6 +263,18 @@ const CONFIG_TEMPLATE = `{ // Host address for web UI (use 127.0.0.1 for local only, 0.0.0.0 for network access) "webServerHost": "127.0.0.1", + + // HTTP Basic Auth for the web UI (recommended whenever webServerHost != 127.0.0.1). + // Leave webServerAuthPassword unset to keep the UI open (the previous default). + // When set, the server demands HTTP Basic Auth credentials on every request. + // The browser's native Basic Auth dialog handles the prompt; closing the + // browser discards the cached credentials, so reopening requires signing in again. + // Accepts the same secret formats as memoryApiKey: + // "literal-value" direct plaintext + // "env://SOME_ENV_VAR" resolved from an environment variable + // "file:///path/to/secret" read from a file (chmod 600 recommended) + // "webServerAuthPassword": "", + // "webServerAuthUsername": "", // ============================================ // Database Settings @@ -588,6 +608,8 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { webServerEnabled: fileConfig.webServerEnabled ?? DEFAULTS.webServerEnabled, webServerPort: fileConfig.webServerPort ?? DEFAULTS.webServerPort, webServerHost: fileConfig.webServerHost ?? DEFAULTS.webServerHost, + webServerAuthPassword: resolveSecretValue(fileConfig.webServerAuthPassword), + webServerAuthUsername: fileConfig.webServerAuthUsername, maxVectorsPerShard: fileConfig.maxVectorsPerShard ?? DEFAULTS.maxVectorsPerShard, autoCleanupEnabled: fileConfig.autoCleanupEnabled ?? DEFAULTS.autoCleanupEnabled, autoCleanupRetentionDays: diff --git a/src/index.ts b/src/index.ts index 12bf94b..1e20196 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { performAutoCapture } from "./services/auto-capture.js"; import { performUserProfileLearning } from "./services/user-memory-learning.js"; import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js"; import { startWebServer, WebServer } from "./services/web-server.js"; +import { WebAuth } from "./services/web-auth.js"; import { isConfigured, CONFIG, initConfig } from "./config.js"; import { log } from "./services/logger.js"; @@ -108,10 +109,15 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { })(); if (CONFIG.webServerEnabled) { + const webAuth = new WebAuth({ + password: CONFIG.webServerAuthPassword, + username: CONFIG.webServerAuthUsername, + }); startWebServer({ port: CONFIG.webServerPort, host: CONFIG.webServerHost, enabled: CONFIG.webServerEnabled, + auth: webAuth, }) .then((server) => { webServer = server; @@ -138,7 +144,9 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { .showToast({ body: { title: "Memory Explorer", - message: `Web UI started at ${url}`, + message: webAuth.isEnabled() + ? `Web UI started at ${url} (auth required)` + : `Web UI started at ${url}`, variant: "success", duration: 5000, }, diff --git a/src/services/cors.ts b/src/services/cors.ts index 0cd5c29..6840b12 100644 --- a/src/services/cors.ts +++ b/src/services/cors.ts @@ -2,22 +2,39 @@ const ALLOWED_LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); const CORS_ALLOWED_METHODS = "GET, POST, PUT, DELETE, OPTIONS"; const CORS_ALLOWED_HEADERS = "Content-Type"; -export function isAllowedBrowserOrigin(origin: string | null): boolean { +export interface CorsAllowOptions { + /** + * Whether HTTP Basic Auth is enforced by the web server. When true, the + * CORS gate opens up beyond loopback origins — the auth challenge is what + * actually protects memory data, so locking CORS down to loopback is + * unnecessary once Basic Auth is on. + */ + httpAuthEnabled?: boolean; +} + +export function isAllowedBrowserOrigin( + origin: string | null, + options: CorsAllowOptions = {} +): boolean { if (!origin) return true; try { const url = new URL(origin); - return ( - (url.protocol === "http:" || url.protocol === "https:") && - ALLOWED_LOOPBACK_HOSTS.has(url.hostname) - ); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + + if (ALLOWED_LOOPBACK_HOSTS.has(url.hostname)) return true; + + return options.httpAuthEnabled === true; } catch { return false; } } -function corsHeaders(origin: string | null): Record { - if (!origin || !isAllowedBrowserOrigin(origin)) return {}; +function corsHeaders( + origin: string | null, + options: CorsAllowOptions = {} +): Record { + if (!origin || !isAllowedBrowserOrigin(origin, options)) return {}; return { "Access-Control-Allow-Origin": origin, @@ -27,17 +44,17 @@ function corsHeaders(origin: string | null): Record { }; } -export function corsPreflightResponse(req: Request): Response { +export function corsPreflightResponse(req: Request, options: CorsAllowOptions = {}): Response { const origin = req.headers.get("Origin"); - if (!isAllowedBrowserOrigin(origin)) { + if (!isAllowedBrowserOrigin(origin, options)) { return disallowedCorsResponse(); } return new Response(null, { status: 204, headers: { - ...corsHeaders(origin), + ...corsHeaders(origin, options), "Access-Control-Max-Age": "600", }, }); diff --git a/src/services/web-auth.ts b/src/services/web-auth.ts new file mode 100644 index 0000000..8b07ec0 --- /dev/null +++ b/src/services/web-auth.ts @@ -0,0 +1,136 @@ +import { timingSafeEqual } from "node:crypto"; +import { userInfo } from "node:os"; + +export const WEB_AUTH_REALM = "OpenCode Memory Explorer"; + +export interface WebAuthOptions { + /** + * Plain-text password used for HTTP Basic Auth. Empty / undefined disables + * auth entirely (the previous default). The caller is responsible for + * resolving `env://` / `file://` shortcuts via the shared + * `resolveSecretValue` helper before passing the value in. + */ + password?: string; + /** + * Plain-text username. Defaults to `os.userInfo().username` so that, when + * the server is bound to a LAN-reachable interface, the obvious choice + * is the OS account that launched opencode. + */ + username?: string; +} + +export interface WebAuthConfig { + enabled: boolean; + username: string; +} + +export interface AuthCheckResult { + ok: boolean; + response?: Response; +} + +export class WebAuth { + private readonly enabled: boolean; + private readonly username: string; + private readonly expectedUsername: Buffer; + private readonly expectedPassword: Buffer; + + constructor(options: WebAuthOptions = {}) { + const password = (options.password ?? "").trim(); + this.enabled = password.length > 0; + const explicitUsername = (options.username ?? "").trim(); + this.username = explicitUsername || safeOsUsername(); + this.expectedUsername = Buffer.from(this.username, "utf8"); + this.expectedPassword = Buffer.from(password, "utf8"); + } + + getConfig(): WebAuthConfig { + return { enabled: this.enabled, username: this.username }; + } + + isEnabled(): boolean { + return this.enabled; + } + + /** + * Validate the HTTP Basic Auth credentials on the incoming request. + * + * Returns `{ ok: true }` when auth is disabled, when the request is for a + * path the user-facing browser must reach without credentials (the health + * probe), or when the supplied `Authorization: Basic …` header matches the + * configured username/password. + * + * Otherwise returns a fully-formed 401 Response carrying the + * `WWW-Authenticate` challenge so browsers pop their native login dialog. + */ + check(req: Request, path: string): AuthCheckResult { + if (!this.enabled) return { ok: true }; + + if (path === "/api/health") return { ok: true }; + + const header = req.headers.get("Authorization"); + if (header) { + const decoded = decodeBasicAuth(header); + if ( + decoded && + constantTimeEquals(decoded.username, this.expectedUsername) && + constantTimeEquals(decoded.password, this.expectedPassword) + ) { + return { ok: true }; + } + } + + return { ok: false, response: this.challenge() }; + } + + challenge(): Response { + return new Response("Authentication required", { + status: 401, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "WWW-Authenticate": `Basic realm="${WEB_AUTH_REALM}", charset="UTF-8"`, + "Cache-Control": "no-store", + }, + }); + } +} + +function safeOsUsername(): string { + try { + const info = userInfo(); + if (info.username && info.username.length > 0) return info.username; + } catch { + // userInfo can throw on some sandboxes; fall through to env fallbacks. + } + if (process.env.USER && process.env.USER.length > 0) return process.env.USER; + if (process.env.USERNAME && process.env.USERNAME.length > 0) return process.env.USERNAME; + return "user"; +} + +function constantTimeEquals(provided: string, expected: Buffer): boolean { + const providedBuf = Buffer.from(provided ?? "", "utf8"); + if (providedBuf.length !== expected.length) { + // Run a dummy compare so the call duration stays independent of length. + timingSafeEqual(expected, expected); + return false; + } + return timingSafeEqual(providedBuf, expected); +} + +function decodeBasicAuth(header: string): { username: string; password: string } | null { + if (!header.toLowerCase().startsWith("basic ")) return null; + const encoded = header.slice(6).trim(); + if (!encoded) return null; + let decoded: string; + try { + decoded = Buffer.from(encoded, "base64").toString("utf8"); + } catch { + return null; + } + const colon = decoded.indexOf(":"); + if (colon === -1) return null; + return { + username: decoded.slice(0, colon), + password: decoded.slice(colon + 1), + }; +} diff --git a/src/services/web-server.ts b/src/services/web-server.ts index f28a5bf..b06d19c 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -5,6 +5,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { log } from "./logger.js"; import { corsPreflightResponse, disallowedCorsResponse, isAllowedBrowserOrigin } from "./cors.js"; +import { WebAuth } from "./web-auth.js"; import { handleListTags, handleListMemories, @@ -157,6 +158,7 @@ interface WebServerConfig { port: number; host: string; enabled: boolean; + auth?: WebAuth; } export class WebServer { @@ -295,7 +297,7 @@ export class WebServer { async checkServerAvailable(): Promise { try { - const response = await fetch(`${this.getUrl()}/api/stats`, { + const response = await fetch(`${this.getUrl()}/api/health`, { method: "GET", signal: AbortSignal.timeout(2000), }); @@ -313,15 +315,33 @@ export class WebServer { const method = req.method; const origin = req.headers.get("Origin"); - if (!isAllowedBrowserOrigin(origin)) { + const auth = this.config.auth; + const corsOptions = { httpAuthEnabled: auth?.isEnabled() ?? false }; + + if (!isAllowedBrowserOrigin(origin, corsOptions)) { return disallowedCorsResponse(); } if (method === "OPTIONS") { - return corsPreflightResponse(req); + return corsPreflightResponse(req, corsOptions); + } + + if (auth && auth.isEnabled()) { + const authCheck = auth.check(req, path); + if (!authCheck.ok && authCheck.response) { + return authCheck.response; + } } try { + if (path === "/api/health" && method === "GET") { + return this.jsonResponse({ + success: true, + status: "ok", + authEnabled: auth?.isEnabled() ?? false, + }); + } + if (path === "/" || path === "/index.html") { return this.serveStaticFile("index.html", "text/html"); } diff --git a/src/web/app.js b/src/web/app.js index de6c887..a8d8394 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -1813,6 +1813,50 @@ function escapeHtml(text) { return div.innerHTML; } +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +function isLoopbackHostname(hostname) { + if (!hostname) return false; + if (LOOPBACK_HOSTS.has(hostname.toLowerCase())) return true; + // IPv4-mapped IPv6 loopback (e.g. "::ffff:127.0.0.1") and friends. + if (hostname.startsWith("::ffff:")) { + return isLoopbackHostname(hostname.slice(7)); + } + return false; +} + +async function checkAuthWarning() { + const banner = document.getElementById("auth-warning"); + if (!banner) return; + + // Trust the server's report of auth state over guessing from a possibly + // proxied Host header. The /api/health endpoint is intentionally + // unauthenticated and exempt from CORS so this fetch is always cheap. + let authEnabled = false; + try { + const response = await fetch("/api/health", { credentials: "same-origin" }); + if (response.ok) { + const data = await response.json(); + authEnabled = data && data.authEnabled === true; + } + } catch (error) { + // If the probe fails the warning is moot — the UI is unusable anyway. + return; + } + + if (authEnabled) { + banner.classList.add("hidden"); + return; + } + + if (isLoopbackHostname(window.location.hostname)) { + banner.classList.add("hidden"); + return; + } + + banner.classList.remove("hidden"); +} + document.addEventListener("DOMContentLoaded", async () => { document.getElementById("tab-project").addEventListener("click", () => switchView("project")); document.getElementById("tab-profile").addEventListener("click", () => switchView("profile")); @@ -1945,6 +1989,7 @@ document.addEventListener("DOMContentLoaded", async () => { await loadMemories(); await loadStats(); await checkMigrationStatus(); + checkAuthWarning(); startAutoRefresh(); diff --git a/src/web/i18n.js b/src/web/i18n.js index f5d28d9..78e04ff 100644 --- a/src/web/i18n.js +++ b/src/web/i18n.js @@ -141,6 +141,7 @@ const translations = { "migration-dimension-mismatch": "dimension mismatch detected", "migration-mismatch-details": "Model mismatch: Config uses {configDimensions}D ({configModel}), but {shardInfo}.", + "auth-warning-text": "Cross-origin editing disabled. Set webServerAuthPassword to enable.", }, zh: { title: "┌─ OPENCODE MEMORY EXPLORER ─┐", @@ -283,6 +284,7 @@ const translations = { "migration-dimension-mismatch": "检测到维度不匹配", "migration-mismatch-details": "模型不匹配:配置使用 {configDimensions}D ({configModel}),但{shardInfo}。", + "auth-warning-text": "跨源编辑已禁用. 设置 webServerAuthPassword 即可启用.", }, ar: { title: "┌─ مستكشف ذاكرة OpenCode ─┐", @@ -464,6 +466,8 @@ const translations = { "migration-mismatch-details": "عدم تطابق النموذج: يستخدم الإعداد {configDimensions}D ({configModel}) بينما {shardInfo}.", + + "auth-warning-text": "التحرير عبر المصادر معطّل. قم بتعيين webServerAuthPassword لتفعيله.", }, }; diff --git a/src/web/index.html b/src/web/index.html index 0879b28..930040a 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -14,6 +14,15 @@
+ +

┌─ OPENCODE MEMORY EXPLORER ─┐

diff --git a/src/web/styles.css b/src/web/styles.css index 4808a75..4e71d14 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -2274,3 +2274,37 @@ textarea:focus-visible { font-size: 12px; color: var(--text-secondary, #888); } + +.auth-warning { + display: flex; + align-items: flex-start; + gap: 12px; + border: 1px solid #ffcc00; + background: rgba(255, 204, 0, 0.08); + color: #ffcc00; + padding: 12px 16px; + margin-bottom: 16px; + font-size: 12px; + line-height: 1.5; +} +.auth-warning.hidden { + display: none; +} +.auth-warning-icon { + font-size: 18px; + line-height: 1; + flex-shrink: 0; +} +.auth-warning-icon svg { + width: 18px; + height: 18px; + stroke: #ffcc00; +} +.auth-warning-body { + display: flex; + flex-direction: column; + gap: 4px; +} +.auth-warning-text { + color: #e0e0e0; +} diff --git a/tests/web-auth.test.ts b/tests/web-auth.test.ts new file mode 100644 index 0000000..f8b81d2 --- /dev/null +++ b/tests/web-auth.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { userInfo } from "node:os"; +import { WEB_AUTH_REALM, WebAuth } from "../src/services/web-auth.js"; + +function basicHeader(user: string, pass: string): string { + const encoded = Buffer.from(`${user}:${pass}`, "utf8").toString("base64"); + return `Basic ${encoded}`; +} + +describe("WebAuth", () => { + beforeEach(() => {}); + + afterEach(() => {}); + + describe("configuration", () => { + it("is disabled when password is empty", () => { + const auth = new WebAuth({ password: "" }); + expect(auth.isEnabled()).toBe(false); + }); + + it("is disabled when password is whitespace only", () => { + const auth = new WebAuth({ password: " " }); + expect(auth.isEnabled()).toBe(false); + }); + + it("is disabled when password is omitted", () => { + const auth = new WebAuth(); + expect(auth.isEnabled()).toBe(false); + }); + + it("is enabled when password is set", () => { + const auth = new WebAuth({ password: "secret" }); + expect(auth.isEnabled()).toBe(true); + }); + + it("defaults username to os.userInfo().username when not provided", () => { + const expected = userInfo().username; + const auth = new WebAuth({ password: "secret" }); + expect(auth.getConfig().username).toBe(expected); + }); + + it("uses explicit username when provided", () => { + const auth = new WebAuth({ password: "secret", username: "custom-admin" }); + expect(auth.getConfig().username).toBe("custom-admin"); + }); + + it("treats whitespace-only username as missing and falls back to OS user", () => { + const expected = userInfo().username; + const auth = new WebAuth({ password: "secret", username: " " }); + expect(auth.getConfig().username).toBe(expected); + }); + }); + + describe("check (HTTP Basic Auth)", () => { + it("passes through when auth is disabled", () => { + const auth = new WebAuth(); + const result = auth.check(new Request("http://x/api/memories"), "/api/memories"); + expect(result.ok).toBe(true); + }); + + it("always allows the health endpoint through", () => { + const auth = new WebAuth({ password: "secret" }); + const result = auth.check(new Request("http://x/api/health"), "/api/health"); + expect(result.ok).toBe(true); + }); + + it("rejects requests without an Authorization header", () => { + const auth = new WebAuth({ password: "secret", username: "admin" }); + const result = auth.check(new Request("http://x/api/memories"), "/api/memories"); + expect(result.ok).toBe(false); + expect(result.response?.status).toBe(401); + expect(result.response?.headers.get("WWW-Authenticate")).toContain( + `Basic realm="${WEB_AUTH_REALM}"` + ); + }); + + it("rejects requests with the wrong credentials", () => { + const auth = new WebAuth({ password: "right", username: "admin" }); + const req = new Request("http://x/api/memories", { + headers: { Authorization: basicHeader("admin", "wrong") }, + }); + const result = auth.check(req, "/api/memories"); + expect(result.ok).toBe(false); + expect(result.response?.status).toBe(401); + }); + + it("rejects requests with a non-Basic Authorization scheme", () => { + const auth = new WebAuth({ password: "secret" }); + const req = new Request("http://x/api/memories", { + headers: { Authorization: "Bearer not-a-basic-token" }, + }); + const result = auth.check(req, "/api/memories"); + expect(result.ok).toBe(false); + }); + + it("rejects requests with malformed Basic payload (no colon)", () => { + const auth = new WebAuth({ password: "secret" }); + const encoded = Buffer.from("no-colon-here", "utf8").toString("base64"); + const req = new Request("http://x/api/memories", { + headers: { Authorization: `Basic ${encoded}` }, + }); + const result = auth.check(req, "/api/memories"); + expect(result.ok).toBe(false); + }); + + it("accepts requests with the correct credentials", () => { + const auth = new WebAuth({ password: "secret", username: "admin" }); + const req = new Request("http://x/api/memories", { + headers: { Authorization: basicHeader("admin", "secret") }, + }); + const result = auth.check(req, "/api/memories"); + expect(result.ok).toBe(true); + }); + + it("treats username case-sensitively", () => { + const auth = new WebAuth({ password: "secret", username: "Admin" }); + const wrongCase = new Request("http://x/api/memories", { + headers: { Authorization: basicHeader("admin", "secret") }, + }); + expect(auth.check(wrongCase, "/api/memories").ok).toBe(false); + + const rightCase = new Request("http://x/api/memories", { + headers: { Authorization: basicHeader("Admin", "secret") }, + }); + expect(auth.check(rightCase, "/api/memories").ok).toBe(true); + }); + + it("accepts credentials whose password contains a colon", () => { + const auth = new WebAuth({ password: "pa:ss:word", username: "admin" }); + const req = new Request("http://x/api/memories", { + headers: { Authorization: basicHeader("admin", "pa:ss:word") }, + }); + expect(auth.check(req, "/api/memories").ok).toBe(true); + }); + }); + + describe("challenge response", () => { + it("uses the configured realm in WWW-Authenticate", () => { + const auth = new WebAuth({ password: "secret" }); + const response = auth.challenge(); + expect(response.status).toBe(401); + const header = response.headers.get("WWW-Authenticate") ?? ""; + expect(header.startsWith("Basic")).toBe(true); + expect(header).toContain(`realm="${WEB_AUTH_REALM}"`); + expect(header).toContain('charset="UTF-8"'); + }); + + it("marks the challenge as no-store so browsers do not cache it", () => { + const auth = new WebAuth({ password: "secret" }); + const response = auth.challenge(); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); + }); +}); diff --git a/tests/web-server-cors.test.ts b/tests/web-server-cors.test.ts index c1915fa..26dd3ed 100644 --- a/tests/web-server-cors.test.ts +++ b/tests/web-server-cors.test.ts @@ -6,42 +6,83 @@ import { } from "../src/services/cors.js"; describe("web server CORS policy", () => { - it("allows non-browser requests without an Origin header", () => { - expect(isAllowedBrowserOrigin(null)).toBe(true); - }); + describe("isAllowedBrowserOrigin", () => { + it("allows non-browser requests without an Origin header", () => { + expect(isAllowedBrowserOrigin(null)).toBe(true); + }); - it("allows loopback browser origins", () => { - expect(isAllowedBrowserOrigin("http://127.0.0.1:4747")).toBe(true); - expect(isAllowedBrowserOrigin("http://localhost:4747")).toBe(true); - expect(isAllowedBrowserOrigin("http://[::1]:4747")).toBe(true); - }); + it("allows loopback browser origins", () => { + expect(isAllowedBrowserOrigin("http://127.0.0.1:4747")).toBe(true); + expect(isAllowedBrowserOrigin("http://localhost:4747")).toBe(true); + expect(isAllowedBrowserOrigin("http://[::1]:4747")).toBe(true); + }); - it("rejects non-loopback browser origins", () => { - expect(isAllowedBrowserOrigin("https://example.com")).toBe(false); - expect(isAllowedBrowserOrigin("null")).toBe(false); - }); + it("rejects non-loopback browser origins when auth is disabled", () => { + expect(isAllowedBrowserOrigin("https://example.com")).toBe(false); + expect(isAllowedBrowserOrigin("http://192.168.1.50:4747")).toBe(false); + expect(isAllowedBrowserOrigin("null")).toBe(false); + }); + + it("rejects non-loopback origins when httpAuthEnabled is false", () => { + expect(isAllowedBrowserOrigin("https://example.com", { httpAuthEnabled: false })).toBe(false); + }); + + it("allows any http(s) non-loopback origin when httpAuthEnabled is true", () => { + expect(isAllowedBrowserOrigin("https://example.com", { httpAuthEnabled: true })).toBe(true); + expect(isAllowedBrowserOrigin("http://192.168.1.50:4747", { httpAuthEnabled: true })).toBe( + true + ); + }); - it("returns a loopback-bound preflight response", () => { - const response = corsPreflightResponse( - new Request("http://127.0.0.1:4747/api/memories", { - method: "OPTIONS", - headers: { - Origin: "http://localhost:4747", - "Access-Control-Request-Method": "POST", - }, - }) - ); - - expect(response.status).toBe(204); - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:4747"); - expect(response.headers.get("Access-Control-Allow-Methods")).toContain("POST"); - expect(response.headers.get("Vary")).toBe("Origin"); + it("still rejects non-http(s) origins even when httpAuthEnabled is true", () => { + expect(isAllowedBrowserOrigin("file:///etc/passwd", { httpAuthEnabled: true })).toBe(false); + expect(isAllowedBrowserOrigin("null", { httpAuthEnabled: true })).toBe(false); + }); + + it("treats malformed origins as rejected", () => { + expect(isAllowedBrowserOrigin("not-a-url", { httpAuthEnabled: true })).toBe(false); + }); }); - it("does not expose CORS headers on rejected origins", () => { - const response = disallowedCorsResponse(); + describe("corsPreflightResponse", () => { + it("returns a loopback-bound preflight response", () => { + const response = corsPreflightResponse( + new Request("http://127.0.0.1:4747/api/memories", { + method: "OPTIONS", + headers: { + Origin: "http://localhost:4747", + "Access-Control-Request-Method": "POST", + }, + }) + ); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:4747"); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain("POST"); + expect(response.headers.get("Vary")).toBe("Origin"); + }); + + it("accepts non-loopback origins once httpAuthEnabled is true", () => { + const response = corsPreflightResponse( + new Request("https://example.com/api/memories", { + method: "OPTIONS", + headers: { + Origin: "https://example.com", + "Access-Control-Request-Method": "POST", + }, + }), + { httpAuthEnabled: true } + ); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://example.com"); + }); + + it("does not expose CORS headers on rejected origins", () => { + const response = disallowedCorsResponse(); - expect(response.status).toBe(403); - expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.status).toBe(403); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); }); });