Source: Source pull request number: 294 in rohitg00/agentmemory (URL omitted to avoid GitHub cross-reference)
Title: fix(security): reject non-loopback Host headers in viewer server (DNS rebinding, CWE-350)
Author: aaronjmars
State: closed
Draft: no
Merged: yes
Head: unknown:security/viewer-dns-rebinding-host-check @ db0821c
Base: main @ c3a613a
Labels: (none)
Changed files: 0
Commits: 0
Created: 2026-05-11T23:41:13Z
Updated: 2026-05-17T09:15:09Z
Closed: 2026-05-17T09:15:09Z
Merged at: 2026-05-17T09:15:09Z
Original PR body:
Disclosure path
Patch branch has been carried privately since 2026-05-11 while I tried the GHSA route — gh api /repos/rohitg00/agentmemory/security-advisories returned 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 HTTP Host header. A DNS-rebinding attacker can lure a victim to attacker.com, short-TTL-rebind the hostname to 127.0.0.1, and read every memory the user has stored — full bypass of the VIEWER_ALLOWED_ORIGINS CORS allowlist because the request is, from the browser's perspective, same-origin.
Impact
src/viewer/server.ts binds to 127.0.0.1 and proxies every request to the local REST API with the AGENTMEMORY_SECRET attached as a Bearer token. The Origin allowlist works against a plain cross-origin attacker page — fetch to localhost:3113 from attacker.com triggers a CORS check, server reflects Access-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 registers
attacker.com, runs an HTTP server on :3113 with a public IP and short TTL.
- Victim browses
http://attacker.com:3113. Page loads, JS waits for the DNS cache TTL to expire (60–120 s in modern browsers).
- Attacker DNS now returns
127.0.0.1. Next lookup for attacker.com lands on loopback.
- JS fires
fetch('/agentmemory/export'). URL = http://attacker.com:3113/agentmemory/export — same origin as the page that ran the script. CORS is not invoked.
- TCP connection lands on
127.0.0.1:3113 (the viewer). Host: attacker.com:3113. The viewer never looks at Host, proxies the request to 127.0.0.1:3111/agentmemory/export with the bearer attached, returns the body.
- Attacker's JS reads the full memory export and exfiltrates it.
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 in README.md is broken end-to-end.
Proof (local repro — no DNS gymnastics needed)
# Terminal 1
npm start # wait for `[agentmemory] Viewer: http://localhost:3113`
# Terminal 2
curl -i -H 'Host: attacker.com:3113' http://127.0.0.1:3113/agentmemory/livez
# 200 OK — request is processed; in a real rebind flow the JS reads the body.
curl -s -H 'Host: attacker.com:3113' http://127.0.0.1:3113/agentmemory/export | head -c 200
# Returns the export JSON. With AGENTMEMORY_SECRET configured, the viewer
# adds the bearer for you — the response is fully authenticated.
I am not attaching a working DNS-rebind chain per disclosure hygiene; the curl repro is the primitive, and the rest is standard rebind.network / dheriot-style payload (already used against etcd, Kubelet, Plex, Home Assistant, etc.).
Fix shape
- Derive a
Host-header allowlist from VIEWER_ALLOWED_ORIGINS plus the viewer's actual listen port (resolved post-listen() so port=0 unit tests work) plus a VIEWER_ALLOWED_HOSTS override env var for the rare reverse-proxy case.
- Reject any other
Host with 403 forbidden host before the route logic runs (HTML branch and proxy branch).
buildAllowedHosts and isHostAllowed exported as pure functions.
- 13 new tests in
test/viewer-security.test.ts:
- 8 predicate tests (loopback accepted; attacker hosts rejected; case insensitivity; malformed entries; missing/empty headers; port mismatch; operator-supplied
VIEWER_ALLOWED_ORIGINS honoured).
- 3 end-to-end tests against a real
startViewerServer socket using node:http.request directly (global fetch strips Host — undici overrides it with the URL authority, so it cannot simulate the rebind payload). Each test confirms an attacker Host is rejected with 403 forbidden host before the proxy runs and that legitimate loopback hosts still serve the viewer HTML.
Verification
npx vitest run test/viewer-security.test.ts → 13/13 pass
npx vitest run (same exclusion set the npm test script uses) → 867/884 pass / 17 skipped / 0 fail
- The only failing file in
npm run test:all is test/integration.test.ts and only because no live agentmemory server is reachable — identical failure mode against main.
tsc --noEmit --skipLibCheck introduces zero new errors over main.
Sibling findings flagged for awareness (not patched)
integrations/hermes/__init__.py:112 / :135 — urllib.urlopen(req) flagged by semgrep dynamic-urllib-use-detected. The _validate_url scheme allowlist (line 94-97) closes the file:// class via Python 3.7+ redirect-handler scheme restrictions; left alone deliberately.
src/functions/replay.ts:24-34 — SENSITIVE_PATH_PATTERNS uses (s?$) boundary which mis-fires on Pascal-case (/SecretData/... doesn't match) and run-on (/secretSocialMedia.jsonl doesn't match). Low impact (local LLM data exposure on mem::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=ok on website/package-lock.json (only postcss@8.4.31 GHSA-qx2v-qp2m-jg93 MODERATE out 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
- Viewer server now validates incoming Host headers and responds 403 with body "forbidden host" for disallowed values.
- Accepts loopback hosts (including IPv6 and explicit ports) and entries from a configurable allowlist via environment variable.
-
Tests
- Expanded test suite with unit and end-to-end tests covering host validation, case-insensitive matching, malformed origins, and various Host header scenarios.

Local branch:
Fork PR:
Fork decision:
Verification:
Notes:
Source: Source pull request number: 294 in rohitg00/agentmemory (URL omitted to avoid GitHub cross-reference)
Title: fix(security): reject non-loopback Host headers in viewer server (DNS rebinding, CWE-350)
Author: aaronjmars
State: closed
Draft: no
Merged: yes
Head: unknown:security/viewer-dns-rebinding-host-check @ db0821c
Base: main @ c3a613a
Labels: (none)
Changed files: 0
Commits: 0
Created: 2026-05-11T23:41:13Z
Updated: 2026-05-17T09:15:09Z
Closed: 2026-05-17T09:15:09Z
Merged at: 2026-05-17T09:15:09Z
Original PR body:
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
Local branch:
Fork PR:
Fork decision:
Verification:
Notes: