fix(security): reject non-loopback Host headers in viewer server (DNS rebinding, CWE-350)#294
Conversation
|
Someone is attempting to deploy a commit to the rohitg00's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe viewer server now enforces DNS rebinding defense by validating incoming Host headers against an allowlist of loopback addresses and configured CORS origins. Requests with disallowed hosts receive HTTP 403 before proxying to the REST API. ChangesDNS Rebinding Defense via Host Header Allowlist
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/viewer/server.ts (1)
117-136: 💤 Low valueConsider initializing
allowedHostson thelisteningevent instead of lazily per request.Functionally correct, but moving the allowlist computation into a
server.on('listening', ...)handler eliminates the per-request null check and theport-fallback branch (which is unreachable in practice — requests cannot arrive beforelistencompletes). It also makes the invariant "allowedHostsis always set when handling a request" obvious at the type level.♻️ Proposed refactor
- // Computed lazily on first request — `port` may be 0 here (OS-assigned), - // in which case we don't know the real listen port until after listen() - // resolves. server.address() is the authoritative source. - let allowedHosts: Set<string> | null = null; + // Populated on the 'listening' event so we can resolve an OS-assigned + // port (port=0) via server.address() before the first request. + let allowedHosts: Set<string> = new Set(); const server = createServer(async (req, res) => { - if (!allowedHosts) { - const addr = server.address(); - const actualPort = - addr && typeof addr === "object" && "port" in addr - ? (addr.port as number) - : port; - allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS, actualPort); - } if (!isHostAllowed(req.headers.host, allowedHosts)) { res.writeHead(403, { "Content-Type": "text/plain" }); res.end("forbidden host"); return; } @@ server.listen(port, "127.0.0.1", () => { + const addr = server.address(); + const actualPort = + addr && typeof addr === "object" && "port" in addr + ? (addr.port as number) + : port; + allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS, actualPort); console.log(`[agentmemory] Viewer: http://localhost:${port}`); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/viewer/server.ts` around lines 117 - 136, Move the lazy computation of allowedHosts into a server 'listening' handler so allowedHosts is initialized once after the real listen port is known: add server.on('listening', ...) that calls server.address(), computes actualPort, and sets allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS, actualPort); then remove the per-request initialization and null check inside the createServer handler and rely on isHostAllowed(req.headers.host, allowedHosts) (ensuring allowedHosts is non-null by the time requests are handled).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/viewer/server.ts`:
- Around line 117-136: Move the lazy computation of allowedHosts into a server
'listening' handler so allowedHosts is initialized once after the real listen
port is known: add server.on('listening', ...) that calls server.address(),
computes actualPort, and sets allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS,
actualPort); then remove the per-request initialization and null check inside
the createServer handler and rely on isHostAllowed(req.headers.host,
allowedHosts) (ensuring allowedHosts is non-null by the time requests are
handled).
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fc2fcabb-a5ab-4629-bcc2-abf111c45e29
📒 Files selected for processing (2)
src/viewer/server.tstest/viewer-security.test.ts
… rebinding) The viewer (default :3113) binds to 127.0.0.1 and proxies every request to the local REST API with the AGENTMEMORY_SECRET attached as Bearer auth. The Origin allowlist defends against a plain cross-origin attacker page, but it does not defend against DNS rebinding: a victim visiting attacker.com whose authoritative DNS short-TTL-rebinds to 127.0.0.1 ends up issuing same-origin fetches to the viewer (origin and target both http://attacker.com:3113 from the browser's perspective), no preflight fires, and the proxy adds the secret server-side. The attacker page then reads every memory the user has stored. Fix: derive a Host-header allowlist from VIEWER_ALLOWED_ORIGINS plus the viewer's actual listen port (resolved post-listen so port=0 unit tests still work) and reject any other Host with 403 before the route logic runs. An explicit VIEWER_ALLOWED_HOSTS env var is honoured for reverse- proxy setups. Tests cover the predicate plus an end-to-end http.request that confirms an attacker-controlled Host header is rejected before the HTML / proxy paths. Detected by Aeon + manual review. Severity: high CWE-350 (Reliance on Reverse DNS Resolution for a Security-Critical Action)
bd5c50f to
db0821c
Compare
|
Just rebased onto latest main to clear the merge conflict — branch is mergeable now. cc @rohitg00, happy to address any feedback whenever you have a moment. |
Disclosure path
Patch branch has been carried privately since 2026-05-11 while I tried the GHSA route —
gh api /repos/rohitg00/agentmemory/security-advisoriesreturned 403 because PVR is disabled at the repo level. Filing publicly so the fix lands; happy to fold this into a GHSA the moment you flip PVR on. Coordinated with operator before going public.Suggested CVSS 3.1: 7.5 —
AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N. CWE-350 primary (CWE-352 on the write subset).Summary
The viewer (default
:3113) does not validate the HTTPHostheader. A DNS-rebinding attacker can lure a victim toattacker.com, short-TTL-rebind the hostname to127.0.0.1, and read every memory the user has stored — full bypass of theVIEWER_ALLOWED_ORIGINSCORS allowlist because the request is, from the browser's perspective, same-origin.Impact
src/viewer/server.tsbinds to127.0.0.1and proxies every request to the local REST API with theAGENTMEMORY_SECRETattached as aBearertoken. The Origin allowlist works against a plain cross-origin attacker page — fetch tolocalhost:3113fromattacker.comtriggers a CORS check, server reflectsAccess-Control-Allow-Origin: http://localhost:3111(the fallback), browser blocks the JS read. Preflight blocks state-changing non-simple methods the same way.DNS rebinding sidesteps the defence because the browser sees same-origin on both sides:
attacker.com, runs an HTTP server on:3113with a public IP and short TTL.http://attacker.com:3113. Page loads, JS waits for the DNS cache TTL to expire (60–120 s in modern browsers).127.0.0.1. Next lookup forattacker.comlands on loopback.fetch('/agentmemory/export'). URL =http://attacker.com:3113/agentmemory/export— same origin as the page that ran the script. CORS is not invoked.127.0.0.1:3113(the viewer).Host: attacker.com:3113. The viewer never looks atHost, proxies the request to127.0.0.1:3111/agentmemory/exportwith the bearer attached, returns the body.Same primitive lands on every endpoint the viewer proxies — reads (
/export,/memories,/audit,/observations,/sessions,/snapshots) and writes (/remember,/forget,/evict,/import,/governance/memories,/snapshot/restore) — with the user's full secret. The "private memory" guarantee inREADME.mdis broken end-to-end.Proof (local repro — no DNS gymnastics needed)
I am not attaching a working DNS-rebind chain per disclosure hygiene; the
curlrepro is the primitive, and the rest is standardrebind.network/ dheriot-style payload (already used against etcd, Kubelet, Plex, Home Assistant, etc.).Fix shape
Host-header allowlist fromVIEWER_ALLOWED_ORIGINSplus the viewer's actual listen port (resolved post-listen()soport=0unit tests work) plus aVIEWER_ALLOWED_HOSTSoverride env var for the rare reverse-proxy case.Hostwith403 forbidden hostbefore the route logic runs (HTML branch and proxy branch).buildAllowedHostsandisHostAllowedexported as pure functions.test/viewer-security.test.ts:VIEWER_ALLOWED_ORIGINShonoured).startViewerServersocket usingnode:http.requestdirectly (globalfetchstripsHost— undici overrides it with the URL authority, so it cannot simulate the rebind payload). Each test confirms an attackerHostis rejected with403 forbidden hostbefore the proxy runs and that legitimate loopback hosts still serve the viewer HTML.Verification
npx vitest run test/viewer-security.test.ts→ 13/13 passnpx vitest run(same exclusion set thenpm testscript uses) → 867/884 pass / 17 skipped / 0 failnpm run test:allistest/integration.test.tsand only because no live agentmemory server is reachable — identical failure mode againstmain.tsc --noEmit --skipLibCheckintroduces zero new errors overmain.Sibling findings flagged for awareness (not patched)
integrations/hermes/__init__.py:112/:135—urllib.urlopen(req)flagged by semgrepdynamic-urllib-use-detected. The_validate_urlscheme allowlist (line 94-97) closes thefile://class via Python 3.7+ redirect-handler scheme restrictions; left alone deliberately.src/functions/replay.ts:24-34—SENSITIVE_PATH_PATTERNSuses(s?$)boundary which mis-fires on Pascal-case (/SecretData/...doesn't match) and run-on (/secretSocialMedia.jsonldoesn't match). Low impact (local LLM data exposure onmem::compress-file), but worth a regex tightening if you want it bundled — happy to send a follow-up patch.Detected by
Aeon (vuln-scanner + manual review). Scanners on this run:
semgrep=ok(2 candidates dropped, hermes urllib above);trufflehog=ok(0 verified secrets, filesystem + full git history);osv-scanner=okonwebsite/package-lock.json(onlypostcss@8.4.31 GHSA-qx2v-qp2m-jg93 MODERATEout of scope per SECURITY.md). Main repo has no committed lockfile so dep-CVE coverage stays at version-range granularity until one ships.Summary by CodeRabbit
Security Improvements
Tests