From eb7642672a46de23161c311564648c8ad4a38ece Mon Sep 17 00:00:00 2001 From: wyh0626 <44987669+wyh0626@users.noreply.github.com> Date: Sun, 17 May 2026 15:20:14 +0800 Subject: [PATCH 1/2] fix(viewer): allow non-loopback bind via AGENTMEMORY_VIEWER_HOST `startViewerServer` hardcoded `server.listen(port, "127.0.0.1")`, so Fly's `fly-local-6pn` traffic was RST and `fly proxy 3113:3113` reported "Recv failure: Connection reset by peer" (#434). Bind host is now read from `AGENTMEMORY_VIEWER_HOST` (default `127.0.0.1`). To keep the viewer's bearer-authorized proxy from silently exposing itself on non-loopback binds, the same code path now: - drops loopback hostnames from the default Host allowlist when bind is non-loopback; - refuses to start unless both `VIEWER_ALLOWED_HOSTS` and `AGENTMEMORY_SECRET` are set in that mode; - requires `Authorization: Bearer $AGENTMEMORY_SECRET` (timing-safe compare from src/auth.ts) on every /agentmemory/* request; HTML and the favicon stay open. `deploy/fly/Dockerfile` no longer bakes `AGENTMEMORY_VIEWER_HOST=::`. The entrypoint exports it (and a `VIEWER_ALLOWED_HOSTS` default scoped to the Host headers `fly proxy` emits) only when `FLY_APP_NAME` or `FLY_ALLOC_ID` is present, so a `docker run -p 3113:3113` of the image elsewhere stays loopback-only. Tests: 29 cases in test/viewer-host.test.ts. --- deploy/fly/README.md | 25 ++++ deploy/fly/entrypoint.sh | 14 ++ src/viewer/server.ts | 140 ++++++++++++++++---- test/viewer-host.test.ts | 269 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 426 insertions(+), 22 deletions(-) create mode 100644 test/viewer-host.test.ts diff --git a/deploy/fly/README.md b/deploy/fly/README.md index 020d9751..af246298 100644 --- a/deploy/fly/README.md +++ b/deploy/fly/README.md @@ -75,6 +75,31 @@ fly proxy 3113:3113 --app "$APP" viewer's bearer token still has to ride a loopback connection on your laptop — the v0.9.12 plaintext-bearer guard stays satisfied. +The entrypoint sets `AGENTMEMORY_VIEWER_HOST=::` **only when it detects +Fly's runtime variables** (`FLY_APP_NAME` / `FLY_ALLOC_ID`). That makes +the viewer listen on the machine's `fly-local-6pn` WireGuard interface +as well as loopback so `fly proxy` can reach it. The same branch +preseeds `VIEWER_ALLOWED_HOSTS=localhost:3113,127.0.0.1:3113,[::1]:3113`, +which are the Host headers `fly proxy 3113:3113` actually emits on +your laptop. + +When `AGENTMEMORY_VIEWER_HOST` is non-loopback the viewer enforces two +extra guards: it refuses to start unless `VIEWER_ALLOWED_HOSTS` is +explicitly set, and every request to `/agentmemory/*` must present +`Authorization: Bearer $AGENTMEMORY_SECRET`. Static HTML and the +favicon are still served unauthenticated. Browser-based viewer UX +through `fly proxy` is therefore limited until the embedded UI learns +to send the bearer — use `curl` (with the bearer) against the REST +endpoints for now. + +> **Security warning.** Setting `AGENTMEMORY_VIEWER_HOST=0.0.0.0` or +> `::` turns the viewer into a network-reachable proxy that signs every +> upstream call with `AGENTMEMORY_SECRET`. Never enable that outside a +> network you trust (Fly's WireGuard mesh in this template), and never +> set it in a plain `docker run -p 3113:3113 …` on a shared host — the +> entrypoint deliberately skips the override when Fly env vars are +> absent so a plain Docker pull stays loopback-only. + ## Rotate the HMAC secret ```bash diff --git a/deploy/fly/entrypoint.sh b/deploy/fly/entrypoint.sh index ffdd6333..5fd4cc26 100755 --- a/deploy/fly/entrypoint.sh +++ b/deploy/fly/entrypoint.sh @@ -95,4 +95,18 @@ fi AGENTMEMORY_SECRET="$(cat "$HMAC_FILE")" export AGENTMEMORY_SECRET +# The viewer's default 127.0.0.1 bind is unreachable through fly proxy, +# which enters the machine via fly-local-6pn (IPv6). Opt into a +# non-loopback bind ONLY when we're actually inside Fly (detected via +# Fly's runtime variables). A plain `docker run` of this image will not +# see these variables and will keep the safe-by-default loopback bind, +# so it can't silently expose the viewer's bearer-authorized proxy to +# the LAN. VIEWER_ALLOWED_HOSTS is preseeded to the Host headers that +# `fly proxy 3113:3113` actually produces on the operator's laptop. +if [ -n "${FLY_APP_NAME:-}" ] || [ -n "${FLY_ALLOC_ID:-}" ]; then + : "${AGENTMEMORY_VIEWER_HOST:=::}" + : "${VIEWER_ALLOWED_HOSTS:=localhost:3113,127.0.0.1:3113,[::1]:3113}" + export AGENTMEMORY_VIEWER_HOST VIEWER_ALLOWED_HOSTS +fi + exec gosu "$RUN_AS" agentmemory "$@" diff --git a/src/viewer/server.ts b/src/viewer/server.ts index 71598690..3e73068d 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -8,6 +8,7 @@ import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { renderViewerDocument } from "./document.js"; +import { timingSafeCompare } from "../auth.js"; // Self-host the viewer favicon at /favicon.svg instead of an inline // data: URI so the viewer CSP can stay tight at `img-src 'self'`. @@ -47,30 +48,52 @@ const ALLOWED_ORIGINS = ( // // Explicit override via VIEWER_ALLOWED_HOSTS for the rare case of a // reverse-proxy in front of the viewer; defaults are computed from the -// listen port at server-create time. -const ALLOWED_HOSTS_OVERRIDE = (process.env.VIEWER_ALLOWED_HOSTS || "") - .split(",") - .map((h) => h.trim().toLowerCase()) - .filter(Boolean); +// listen port at server-create time. Read at call time so tests and +// runtime overrides can rotate the value without a module reload. +function readAllowedHostsOverride(): string[] { + return (process.env.VIEWER_ALLOWED_HOSTS || "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); +} + +export function resolveViewerHost(): string { + return process.env.AGENTMEMORY_VIEWER_HOST?.trim() || "127.0.0.1"; +} + +export function isLoopbackHost(host: string): boolean { + const h = host.trim().toLowerCase(); + return h === "127.0.0.1" || h === "::1" || h === "localhost"; +} export function buildAllowedHosts( origins: string[], listenPort: number, + bindHost: string = "127.0.0.1", ): Set { const hosts = new Set(); - for (const o of origins) { - try { - const parsed = new URL(o); - if (parsed.host) hosts.add(parsed.host.toLowerCase()); - } catch { - // Skip invalid origin entries — the existing CORS path already - // tolerates them by simply not matching; mirror that here. + // When bind is loopback the listening socket is unreachable from the + // network, so it's safe to seed the allowlist from the CORS origins + // (which by default are localhost-based) plus the standard loopback + // hostnames on the actual listen port. When bind is non-loopback the + // listening socket is reachable from anywhere TCP can reach the + // process, and any of those loopback names becomes a spoofable Host + // header — so only explicit VIEWER_ALLOWED_HOSTS entries are trusted. + if (isLoopbackHost(bindHost)) { + for (const o of origins) { + try { + const parsed = new URL(o); + if (parsed.host) hosts.add(parsed.host.toLowerCase()); + } catch { + // Skip invalid origin entries — the existing CORS path already + // tolerates them by simply not matching; mirror that here. + } } + hosts.add(`localhost:${listenPort}`); + hosts.add(`127.0.0.1:${listenPort}`); + hosts.add(`[::1]:${listenPort}`); } - hosts.add(`localhost:${listenPort}`); - hosts.add(`127.0.0.1:${listenPort}`); - hosts.add(`[::1]:${listenPort}`); - for (const h of ALLOWED_HOSTS_OVERRIDE) hosts.add(h); + for (const h of readAllowedHostsOverride()) hosts.add(h); return hosts; } @@ -84,6 +107,21 @@ export function isHostAllowed( return allowed.has(lower); } +// When bind is non-loopback the viewer is a bearer-authorized proxy +// reachable from the network, so every request that would forward +// upstream must also present the same bearer. Static routes (HTML, +// favicon) stay open so a browser can fetch the shell — the JS inside +// then has to provide a bearer for the API calls. +export function requireInboundBearer( + authHeader: string | string[] | undefined, + secret: string, +): boolean { + if (typeof authHeader !== "string") return false; + const match = /^Bearer\s+(\S+)\s*$/i.exec(authHeader); + if (!match) return false; + return timingSafeCompare(match[1], secret); +} + function corsHeaders(req: IncomingMessage): Record { const origin = req.headers.origin || ""; const allowed = ALLOWED_ORIGINS.includes(origin) @@ -141,6 +179,13 @@ export function getViewerSkipped(): boolean { return viewerSkipped; } +export class ViewerConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ViewerConfigError"; + } +} + export function startViewerServer( port: number, _kv: unknown, @@ -154,6 +199,28 @@ export function startViewerServer( const resolvedRestPort = restPort ?? port - 2; const requestedPort = port; + const host = resolveViewerHost(); + let inboundSecret: string | null = null; + + // Non-loopback bind turns the viewer into a network-reachable + // bearer-authorized proxy. Refuse to start unless the operator has + // both an inbound secret to authenticate callers against and an + // explicit Host header allowlist; otherwise the listening socket + // becomes an open relay to the local REST API. + if (!isLoopbackHost(host)) { + if (!secret) { + throw new ViewerConfigError( + `AGENTMEMORY_VIEWER_HOST=${host} requires AGENTMEMORY_SECRET to be set so the viewer can validate inbound bearer tokens. To fix: unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind, or set AGENTMEMORY_SECRET. For Fly images, it is printed on first boot; see deploy/fly/README.md.`, + ); + } + if (readAllowedHostsOverride().length === 0) { + throw new ViewerConfigError( + `AGENTMEMORY_VIEWER_HOST=${host} requires VIEWER_ALLOWED_HOSTS because non-loopback viewer binds only trust explicit Host headers. To fix: set VIEWER_ALLOWED_HOSTS to a comma-separated list of trusted Host header values (e.g. "localhost:3113" for fly proxy), or unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind.`, + ); + } + inboundSecret = secret; + } + // Computed lazily on first request — `port` may be 0 here (OS-assigned) // or the EADDRINUSE retry loop below may bump us to a different port, // so we read the actual bound port from server.address() on first hit. @@ -166,7 +233,7 @@ export function startViewerServer( addr && typeof addr === "object" && "port" in addr ? (addr.port as number) : port; - allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS, actualPort); + allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS, actualPort, host); } if (!isHostAllowed(req.headers.host, allowedHosts)) { res.writeHead(403, { "Content-Type": "text/plain" }); @@ -225,6 +292,18 @@ export function startViewerServer( return; } + if ( + inboundSecret !== null && + !requireInboundBearer(req.headers.authorization, inboundSecret) + ) { + res.writeHead(401, { + "Content-Type": "text/plain", + "WWW-Authenticate": 'Bearer realm="agentmemory-viewer"', + }); + res.end("unauthorized"); + return; + } + try { await proxyToRestApi(resolvedRestPort, pathname, qs, method, req, res, secret); } catch (err) { @@ -237,7 +316,7 @@ export function startViewerServer( let currentPort = requestedPort; const tryListen = (): void => { - server.listen(currentPort, "127.0.0.1"); + server.listen(currentPort, host); }; server.on("listening", () => { @@ -247,6 +326,13 @@ export function startViewerServer( ? addr.port : currentPort; viewerSkipped = false; + if (inboundSecret !== null) { + const allowedHosts = readAllowedHostsOverride().join(", "); + console.log( + `[agentmemory] Viewer: http://localhost:${currentPort} (bound to ${host}; inbound Bearer required; allowed Host headers: ${allowedHosts})`, + ); + return; + } if (currentPort === requestedPort) { console.log(`[agentmemory] Viewer: http://localhost:${currentPort}`); } else { @@ -257,7 +343,11 @@ export function startViewerServer( }); server.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE" && attempt < MAX_VIEWER_PORT_RETRIES) { + if ( + err.code === "EADDRINUSE" && + inboundSecret === null && + attempt < MAX_VIEWER_PORT_RETRIES + ) { attempt++; currentPort = requestedPort + attempt; setImmediate(tryListen); @@ -266,9 +356,15 @@ export function startViewerServer( if (err.code === "EADDRINUSE") { boundViewerPort = null; viewerSkipped = true; - console.warn( - `[agentmemory] Viewer ports ${requestedPort}-${requestedPort + MAX_VIEWER_PORT_RETRIES} all in use, skipping viewer.`, - ); + if (inboundSecret !== null) { + console.warn( + `[agentmemory] Viewer port ${requestedPort} is in use while bound to ${host}; not retrying because non-loopback viewer binds require VIEWER_ALLOWED_HOSTS to match the exact port. Free the port, choose another viewer port, or unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind.`, + ); + } else { + console.warn( + `[agentmemory] Viewer ports ${requestedPort}-${requestedPort + MAX_VIEWER_PORT_RETRIES} all in use, skipping viewer.`, + ); + } } else { boundViewerPort = null; viewerSkipped = true; diff --git a/test/viewer-host.test.ts b/test/viewer-host.test.ts new file mode 100644 index 00000000..f1b07780 --- /dev/null +++ b/test/viewer-host.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + buildAllowedHosts, + isLoopbackHost, + requireInboundBearer, + resolveViewerHost, + startViewerServer, + ViewerConfigError, +} from "../src/viewer/server.js"; + +describe("resolveViewerHost", () => { + const originalEnv = process.env.AGENTMEMORY_VIEWER_HOST; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AGENTMEMORY_VIEWER_HOST; + } else { + process.env.AGENTMEMORY_VIEWER_HOST = originalEnv; + } + }); + + it("defaults to 127.0.0.1 when AGENTMEMORY_VIEWER_HOST is unset", () => { + delete process.env.AGENTMEMORY_VIEWER_HOST; + expect(resolveViewerHost()).toBe("127.0.0.1"); + }); + + it("defaults to 127.0.0.1 when AGENTMEMORY_VIEWER_HOST is empty", () => { + process.env.AGENTMEMORY_VIEWER_HOST = ""; + expect(resolveViewerHost()).toBe("127.0.0.1"); + }); + + it("returns the configured value when AGENTMEMORY_VIEWER_HOST is set", () => { + process.env.AGENTMEMORY_VIEWER_HOST = "::"; + expect(resolveViewerHost()).toBe("::"); + }); + + it("trims surrounding whitespace", () => { + process.env.AGENTMEMORY_VIEWER_HOST = " ::1 "; + expect(resolveViewerHost()).toBe("::1"); + }); +}); + +describe("isLoopbackHost", () => { + it.each([ + ["127.0.0.1", true], + ["::1", true], + ["localhost", true], + [" 127.0.0.1 ", true], + ["LOCALHOST", true], + ["::", false], + ["0.0.0.0", false], + ["10.0.0.1", false], + ["fly-local-6pn", false], + ])("classifies %s as loopback=%s", (host, expected) => { + expect(isLoopbackHost(host)).toBe(expected); + }); +}); + +describe("buildAllowedHosts", () => { + const originalOverride = process.env.VIEWER_ALLOWED_HOSTS; + + afterEach(() => { + if (originalOverride === undefined) { + delete process.env.VIEWER_ALLOWED_HOSTS; + } else { + process.env.VIEWER_ALLOWED_HOSTS = originalOverride; + } + }); + + it("seeds loopback defaults and CORS origins when bind is loopback", () => { + delete process.env.VIEWER_ALLOWED_HOSTS; + const allowed = buildAllowedHosts( + ["http://localhost:3111", "http://127.0.0.1:3111"], + 3113, + "127.0.0.1", + ); + expect(allowed.has("localhost:3113")).toBe(true); + expect(allowed.has("127.0.0.1:3113")).toBe(true); + expect(allowed.has("[::1]:3113")).toBe(true); + expect(allowed.has("localhost:3111")).toBe(true); + expect(allowed.has("127.0.0.1:3111")).toBe(true); + }); + + it("drops loopback defaults when bind is non-loopback, leaving only VIEWER_ALLOWED_HOSTS", () => { + process.env.VIEWER_ALLOWED_HOSTS = "viewer.example.com,localhost:3113"; + const allowed = buildAllowedHosts( + ["http://localhost:3111", "http://127.0.0.1:3111"], + 3113, + "::", + ); + expect(allowed.has("viewer.example.com")).toBe(true); + expect(allowed.has("localhost:3113")).toBe(true); // came in via the override + // Origin-derived loopback hostnames must not silently land in the + // allowlist when bind is non-loopback — otherwise Host header + // spoofing reopens the very gap the override is meant to close. + expect(allowed.has("localhost:3111")).toBe(false); + expect(allowed.has("127.0.0.1:3111")).toBe(false); + expect(allowed.has("127.0.0.1:3113")).toBe(false); + expect(allowed.has("[::1]:3113")).toBe(false); + }); + + it("returns an empty set when bind is non-loopback and the override is empty", () => { + delete process.env.VIEWER_ALLOWED_HOSTS; + const allowed = buildAllowedHosts( + ["http://localhost:3111"], + 3113, + "0.0.0.0", + ); + expect(allowed.size).toBe(0); + }); +}); + +describe("requireInboundBearer", () => { + const secret = "s3cr3t-bearer-value"; + + it("accepts a matching Bearer token", () => { + expect(requireInboundBearer(`Bearer ${secret}`, secret)).toBe(true); + }); + + it("accepts case-insensitive scheme", () => { + expect(requireInboundBearer(`bearer ${secret}`, secret)).toBe(true); + }); + + it("rejects a mismatching Bearer token", () => { + expect(requireInboundBearer(`Bearer wrong-token`, secret)).toBe(false); + }); + + it("rejects a missing header", () => { + expect(requireInboundBearer(undefined, secret)).toBe(false); + }); + + it("rejects a non-Bearer scheme", () => { + expect(requireInboundBearer(`Basic ${secret}`, secret)).toBe(false); + }); + + it("rejects an array Authorization header", () => { + expect(requireInboundBearer([`Bearer ${secret}`], secret)).toBe(false); + }); +}); + +describe("startViewerServer host binding", () => { + const originalEnv = process.env.AGENTMEMORY_VIEWER_HOST; + const originalOverride = process.env.VIEWER_ALLOWED_HOSTS; + let server: Server | undefined; + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + if (server) { + await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + } + logSpy.mockRestore(); + if (originalEnv === undefined) { + delete process.env.AGENTMEMORY_VIEWER_HOST; + } else { + process.env.AGENTMEMORY_VIEWER_HOST = originalEnv; + } + if (originalOverride === undefined) { + delete process.env.VIEWER_ALLOWED_HOSTS; + } else { + process.env.VIEWER_ALLOWED_HOSTS = originalOverride; + } + }); + + async function waitForListening(s: Server): Promise { + if (s.listening) return; + await new Promise((resolve) => s.once("listening", () => resolve())); + } + + it("binds to 127.0.0.1 by default — preserves loopback-only security", async () => { + delete process.env.AGENTMEMORY_VIEWER_HOST; + server = startViewerServer(0, null, null); + await waitForListening(server); + const addr = server.address() as AddressInfo; + expect(addr.address).toBe("127.0.0.1"); + }); + + it("binds to AGENTMEMORY_VIEWER_HOST when set — covers the deploy/fly fix for #434", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "::1"; + server = startViewerServer(0, null, null); + await waitForListening(server); + const addr = server.address() as AddressInfo; + expect(addr.address).toBe("::1"); + }); + + it("refuses to start when bind is non-loopback and no AGENTMEMORY_SECRET is configured", () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + process.env.VIEWER_ALLOWED_HOSTS = "viewer.example.com"; + expect(() => startViewerServer(0, null, null)).toThrow(ViewerConfigError); + }); + + it("refuses to start when bind is non-loopback and VIEWER_ALLOWED_HOSTS is empty", () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + delete process.env.VIEWER_ALLOWED_HOSTS; + expect(() => + startViewerServer(0, null, null, "test-secret"), + ).toThrow(ViewerConfigError); + }); + + it("returns 401 for non-Bearer API calls when bind is non-loopback", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + // Pre-seed an entry so refuse-start passes. The request-time + // buildAllowedHosts call will re-read the env, so we widen it after + // the port is known. + process.env.VIEWER_ALLOWED_HOSTS = "placeholder"; + const secret = "test-secret-xyz"; + server = startViewerServer(0, null, null, secret); + await waitForListening(server); + const addr = server.address() as AddressInfo; + process.env.VIEWER_ALLOWED_HOSTS = `127.0.0.1:${addr.port}`; + + const unauthed = await fetch( + `http://127.0.0.1:${addr.port}/agentmemory/livez`, + ); + expect(unauthed.status).toBe(401); + + const wrongBearer = await fetch( + `http://127.0.0.1:${addr.port}/agentmemory/livez`, + { headers: { Authorization: "Bearer wrong-token" } }, + ); + expect(wrongBearer.status).toBe(401); + + // Correct bearer reaches the proxy. Upstream is not running in + // this test, so we expect a non-401 status (the proxy will fail + // with 502/504) — the key invariant is that the auth gate let it + // through. + const goodBearer = await fetch( + `http://127.0.0.1:${addr.port}/agentmemory/livez`, + { headers: { Authorization: `Bearer ${secret}` } }, + ); + expect(goodBearer.status).not.toBe(401); + }); + + it("does not require inbound auth on the loopback default bind", async () => { + delete process.env.AGENTMEMORY_VIEWER_HOST; + delete process.env.VIEWER_ALLOWED_HOSTS; + server = startViewerServer(0, null, null, "test-secret-xyz"); + await waitForListening(server); + const addr = server.address() as AddressInfo; + + const res = await fetch( + `http://127.0.0.1:${addr.port}/agentmemory/livez`, + ); + // No 401: the loopback bind keeps the legacy behaviour where any + // local process is implicitly trusted. Upstream is not running, so + // we expect a proxy-error status, just not the auth gate. + expect(res.status).not.toBe(401); + }); + + it("serves HTML at / on non-loopback bind without requiring a Bearer", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + process.env.VIEWER_ALLOWED_HOSTS = "placeholder"; + server = startViewerServer(0, null, null, "test-secret-xyz"); + await waitForListening(server); + const addr = server.address() as AddressInfo; + process.env.VIEWER_ALLOWED_HOSTS = `127.0.0.1:${addr.port}`; + + const res = await fetch(`http://127.0.0.1:${addr.port}/`); + // The HTML shell stays unauthenticated so a browser can fetch it; + // the embedded JS still needs the bearer for the data calls. + expect(res.status).not.toBe(401); + }); +}); From f80e9a79a6df777a0b0908011a84f270d813be6e Mon Sep 17 00:00:00 2001 From: wyh0626 <44987669+wyh0626@users.noreply.github.com> Date: Wed, 20 May 2026 23:29:37 +0800 Subject: [PATCH 2/2] fix(viewer): secure Fly proxy access --- deploy/fly/README.md | 19 +++++-- src/viewer/index.html | 92 ++++++++++++++++++++++++++++++++++ test/viewer-host.test.ts | 63 ++++++++++++++++++++++- test/viewer-session-id.test.ts | 37 ++++++++++++++ 4 files changed, 205 insertions(+), 6 deletions(-) diff --git a/deploy/fly/README.md b/deploy/fly/README.md index af246298..d7d87513 100644 --- a/deploy/fly/README.md +++ b/deploy/fly/README.md @@ -51,7 +51,15 @@ fly logs --app "$APP" | grep -A1 AGENTMEMORY_SECRET= You will see exactly one line of the form `AGENTMEMORY_SECRET=<64 hex chars>`. Copy it into your client environment (`~/.bashrc`, Claude Desktop config, -etc.). The secret is never printed again on subsequent boots. +the viewer unlock prompt, etc.). The secret is never printed again on +subsequent boots. + +If the first-boot log line is no longer available, read the persisted +secret from the mounted volume: + +```bash +fly ssh console --app "$APP" -C "sh -lc 'cat /data/.hmac'" +``` ## Verify the deployment @@ -87,10 +95,11 @@ When `AGENTMEMORY_VIEWER_HOST` is non-loopback the viewer enforces two extra guards: it refuses to start unless `VIEWER_ALLOWED_HOSTS` is explicitly set, and every request to `/agentmemory/*` must present `Authorization: Bearer $AGENTMEMORY_SECRET`. Static HTML and the -favicon are still served unauthenticated. Browser-based viewer UX -through `fly proxy` is therefore limited until the embedded UI learns -to send the bearer — use `curl` (with the bearer) against the REST -endpoints for now. +favicon are still served unauthenticated. If a proxied viewer request +gets a 401, the browser UI prompts for `AGENTMEMORY_SECRET` and stores +it in session storage so subsequent viewer API calls include the bearer. +Use the value printed in the first-boot logs or read `/data/.hmac` +inside the machine. > **Security warning.** Setting `AGENTMEMORY_VIEWER_HOST=0.0.0.0` or > `::` turns the viewer into a network-reachable proxy that signs every diff --git a/src/viewer/index.html b/src/viewer/index.html index c2c200b8..d5d3824d 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -711,6 +711,46 @@ .flag-close:hover, .flag-close:focus-visible { color: var(--ink); outline: 2px solid var(--border); outline-offset: 1px; } + .viewer-auth { + display: none; + padding: 0 24px 10px 24px; + background: var(--bg); + flex: 0 0 auto; + position: relative; + z-index: 1; + } + .viewer-auth.open { display: block; } + .viewer-auth-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(180px, 320px) auto; + gap: 10px; + align-items: center; + width: min(960px, 100%); + max-width: 960px; + padding: 10px 14px; + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + background: var(--bg-subtle); + font-family: var(--font-ui); + font-size: 12px; + } + .viewer-auth-title { font-weight: 600; color: var(--ink); margin-bottom: 2px; } + .viewer-auth-desc { color: var(--ink-muted); line-height: 1.4; } + .viewer-auth-desc code { font-family: var(--font-mono); font-size: 10px; color: var(--ink); } + .viewer-auth input { + width: 100%; + min-width: 0; + padding: 7px 9px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--ink); + font-family: var(--font-mono); + font-size: 12px; + } + @media (max-width: 900px) { + .viewer-auth-panel { grid-template-columns: 1fr; } + } + /* Viewer footer */ .viewer-footer { margin-top: 48px; padding: 16px 0 24px; @@ -961,6 +1001,7 @@

agentmemory

+
@@ -1063,6 +1104,7 @@

agentmemory

}; var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' }; var TAB_IDS = ['dashboard', 'graph', 'memories', 'timeline', 'sessions', 'lessons', 'actions', 'crystals', 'audit', 'activity', 'profile', 'replay']; + var VIEWER_TOKEN_STORAGE_KEY = 'agentmemory-viewer-token'; var state = { activeTab: 'dashboard', @@ -1159,17 +1201,54 @@

agentmemory

try { el.setSelectionRange(focus.start, focus.end); } catch (e) {} } } + function getViewerToken() { + try { return sessionStorage.getItem(VIEWER_TOKEN_STORAGE_KEY) || ''; } catch (_) { return ''; } + } + function setViewerToken(token) { + try { + if (token) sessionStorage.setItem(VIEWER_TOKEN_STORAGE_KEY, token); + else sessionStorage.removeItem(VIEWER_TOKEN_STORAGE_KEY); + } catch (_) {} + } + function showViewerAuthPrompt() { + var host = document.getElementById('viewer-auth'); + if (!host) return; + host.classList.add('open'); + host.innerHTML = + '
' + + '
' + + '
Viewer authorization required
' + + '
Enter AGENTMEMORY_SECRET to unlock viewer API access.
' + + '
' + + '' + + '' + + '
'; + var input = document.getElementById('viewer-auth-token'); + if (input && typeof input.focus === 'function') input.focus(); + } + function hideViewerAuthPrompt() { + var host = document.getElementById('viewer-auth'); + if (!host) return; + host.classList.remove('open'); + host.innerHTML = ''; + } async function api(path, opts) { try { var url = REST + '/agentmemory/' + path; var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {}); + var viewerToken = getViewerToken(); + if (viewerToken && !headers.Authorization && !headers.authorization) { + headers.Authorization = 'Bearer ' + viewerToken; + } var fetchOpts = Object.assign({}, opts || {}, { headers: headers }); var res = await fetch(url, fetchOpts); if (!res.ok) { + if (res.status === 401) showViewerAuthPrompt(); console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + res.status); return null; } + hideViewerAuthPrompt(); return await res.json(); } catch (err) { console.warn('[viewer] API error on ' + path + ':', err); @@ -3710,6 +3789,19 @@

agentmemory

if (memoryId) confirmDeleteMemory(memoryId); return; } + if (action === 'save-viewer-token') { + var tokenInput = document.getElementById('viewer-auth-token'); + var token = tokenInput ? tokenInput.value.trim() : ''; + if (token) { + setViewerToken(token); + hideViewerAuthPrompt(); + if (state[state.activeTab] && typeof state[state.activeTab] === 'object') { + state[state.activeTab].loaded = false; + } + loadTab(state.activeTab); + } + return; + } if (action === 'timeline-filter') { setTlTypeFilter(target.getAttribute('data-type-filter') || ''); return; diff --git a/test/viewer-host.test.ts b/test/viewer-host.test.ts index f1b07780..b68ecec4 100644 --- a/test/viewer-host.test.ts +++ b/test/viewer-host.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import type { Server } from "node:http"; +import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { buildAllowedHosts, @@ -145,9 +145,11 @@ describe("startViewerServer host binding", () => { const originalOverride = process.env.VIEWER_ALLOWED_HOSTS; let server: Server | undefined; let logSpy: ReturnType; + let warnSpy: ReturnType; beforeEach(() => { logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(async () => { @@ -156,6 +158,7 @@ describe("startViewerServer host binding", () => { server = undefined; } logSpy.mockRestore(); + warnSpy.mockRestore(); if (originalEnv === undefined) { delete process.env.AGENTMEMORY_VIEWER_HOST; } else { @@ -193,6 +196,12 @@ describe("startViewerServer host binding", () => { process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; process.env.VIEWER_ALLOWED_HOSTS = "viewer.example.com"; expect(() => startViewerServer(0, null, null)).toThrow(ViewerConfigError); + expect(() => startViewerServer(0, null, null)).toThrow( + /unset AGENTMEMORY_VIEWER_HOST/, + ); + expect(() => startViewerServer(0, null, null)).toThrow( + /set AGENTMEMORY_SECRET/, + ); }); it("refuses to start when bind is non-loopback and VIEWER_ALLOWED_HOSTS is empty", () => { @@ -201,6 +210,12 @@ describe("startViewerServer host binding", () => { expect(() => startViewerServer(0, null, null, "test-secret"), ).toThrow(ViewerConfigError); + expect(() => startViewerServer(0, null, null, "test-secret")).toThrow( + /set VIEWER_ALLOWED_HOSTS/, + ); + expect(() => startViewerServer(0, null, null, "test-secret")).toThrow( + /unset AGENTMEMORY_VIEWER_HOST/, + ); }); it("returns 401 for non-Bearer API calls when bind is non-loopback", async () => { @@ -266,4 +281,50 @@ describe("startViewerServer host binding", () => { // the embedded JS still needs the bearer for the data calls. expect(res.status).not.toBe(401); }); + + it("logs non-loopback bind mode and inbound auth requirements", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + process.env.VIEWER_ALLOWED_HOSTS = "localhost:3113,[::1]:3113"; + server = startViewerServer(0, null, null, "test-secret-xyz"); + await waitForListening(server); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("bound to 0.0.0.0; inbound Bearer required"), + ); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("allowed Host headers: localhost:3113, [::1]:3113"), + ); + }); + + it("does not retry EADDRINUSE when bind is non-loopback", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + process.env.VIEWER_ALLOWED_HOSTS = "localhost:3113"; + + const blocker = createServer((_req, res) => res.end("busy")); + await new Promise((resolve) => blocker.listen(0, "0.0.0.0", resolve)); + const blockedPort = (blocker.address() as AddressInfo).port; + + try { + const viewer = startViewerServer( + blockedPort, + null, + null, + "test-secret-xyz", + ); + const err = await new Promise((resolve) => + viewer.once("error", resolve), + ); + expect(err.code).toBe("EADDRINUSE"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(viewer.listening).toBe(false); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining(`fallback from ${blockedPort}`), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not retrying because non-loopback viewer binds"), + ); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); }); diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts index 9938a753..a671df88 100644 --- a/test/viewer-session-id.test.ts +++ b/test/viewer-session-id.test.ts @@ -135,6 +135,14 @@ function loadViewerSandbox() { search: "", }, localStorage: { getItem: () => null, setItem: () => {} }, + sessionStorage: (() => { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + })(), fetch: async () => ({ ok: true, json: async () => ({}) }), WebSocket: function WebSocket() {}, navigator: { userAgent: "vitest" }, @@ -169,6 +177,35 @@ function loadViewerSandbox() { } describe("viewer session rendering", () => { + it("attaches the saved viewer bearer to API calls", async () => { + const { sandbox } = loadViewerSandbox(); + const requests: Array<{ url: string; opts: { headers?: Record } }> = []; + sandbox.sessionStorage.setItem("agentmemory-viewer-token", "viewer-secret"); + sandbox.fetch = async (url: string, opts: { headers?: Record }) => { + requests.push({ url, opts }); + return { ok: true, json: async () => ({ ok: true }) }; + }; + + await sandbox.apiGet("health"); + + expect(requests).toHaveLength(1); + expect(requests[0].opts.headers?.Authorization).toBe("Bearer viewer-secret"); + }); + + it("shows where to find AGENTMEMORY_SECRET after a viewer auth failure", async () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.fetch = async () => ({ ok: false, status: 401, json: async () => ({}) }); + + await sandbox.apiGet("health"); + + const prompt = getElement("viewer-auth"); + expect(prompt.classList.contains("open")).toBe(true); + expect(prompt.innerHTML).toContain("AGENTMEMORY_SECRET"); + expect(prompt.innerHTML).toContain("unlock viewer API access"); + expect(prompt.innerHTML).not.toContain("fly logs"); + expect(prompt.innerHTML).not.toContain("/data/.hmac"); + }); + it("does not throw when dashboard sessions are missing ids", () => { const { sandbox, getElement } = loadViewerSandbox(); sandbox.state.dashboard = {