Skip to content

[upstream PR 294] f<!-- -->ix(security): reject non-loopback Host headers in viewer server (DNS rebinding, CWE-350) #755

@wbugitlab1

Description

@wbugitlab1

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:

  1. Attacker registers attacker.com, runs an HTTP server on :3113 with a public IP and short TTL.
  2. Victim browses http://attacker.com:3113. Page loads, JS waits for the DNS cache TTL to expire (60–120 s in modern browsers).
  3. Attacker DNS now returns 127.0.0.1. Next lookup for attacker.com lands on loopback.
  4. 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.
  5. 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.
  6. 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.ts13/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 / :135urllib.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-34SENSITIVE_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.

Review Change Stack

Local branch:
Fork PR:
Fork decision:
Verification:
Notes:

Metadata

Metadata

Assignees

No one assigned

    Labels

    decision-candidateFork decision has not been madeupstream-mergedUpstream pull request is merged upstreamupstream-prTracks an upstream pull request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions