Skip to content

[upstream PR 898] f<!-- -->ix(viewer): prevent path-traversal SSRF in proxy (CWE-918) #395

@wbugitlab1

Description

@wbugitlab1

Source: Source pull request number: 898 in rohitg00/agentmemory (URL omitted to avoid GitHub cross-reference)
Title: fix(viewer): prevent path-traversal SSRF in proxy (CWE-918)
Author: sebastiondev
State: open
Draft: no
Merged: no
Head: sebastiondev/agentmemory:fix/cwe918-server-viewer-11d9 @ 8f7f643
Base: main @ f3dc7f8
Labels: (none)
Changed files: 0
Commits: 0
Created: 2026-06-10T23:19:20Z
Updated: 2026-06-10T23:22:46Z
Closed: (not closed)
Merged at: (not merged)

Original PR body:

Summary

The viewer proxy in src/viewer/server.ts has a path-traversal vulnerability (CWE-918) in the proxyToRestApi() function. An attacker with local HTTP access to the viewer port can escape the intended /agentmemory/ path prefix and reach arbitrary upstream routes while the proxy auto-attaches the AGENTMEMORY_SECRET Bearer token.

This PR adds path normalization before the prefix check, closing the traversal gap.

Vulnerability Details

CWE: CWE-918 (Server-Side Request Forgery)
Affected file: src/viewer/server.ts, function proxyToRestApi() (line 417)
Severity: Medium (exploitable under specific but realistic preconditions)

Data flow

  1. The viewer receives an HTTP request and extracts req.url at line 278.
  2. The raw pathname is split from the query string at line 280 — no normalization occurs.
  3. In proxyToRestApi(), the pathname is checked with startsWith("/agentmemory/") to decide routing.
  4. A path like /agentmemory/../../secret-route passes this prefix check (it literally starts with /agentmemory/).
  5. The path is then embedded into a URL string and passed to fetch(), which internally normalizes .. segments via its URL parser — resolving /agentmemory/../../secret-route to /secret-route.
  6. The request reaches the upstream REST API at /secret-route (outside /agentmemory/) with the privileged Bearer token attached.

The core issue is a TOCTOU mismatch: the prefix check operates on the raw path, but fetch() normalizes it before making the request.

Threat model

The vulnerability is exploitable when:

  • AGENTMEMORY_SECRET is set (REST API authentication is enabled)
  • The viewer is running in default loopback mode (no inbound Bearer required for the viewer itself)
  • An attacker has local HTTP access to the viewer port (3113) but does not know the secret
  • The upstream iii engine exposes routes outside /agentmemory/

In this configuration, the viewer intentionally acts as an unauthenticated bridge to the authenticated /agentmemory/* API surface. The path traversal breaks that constraint by allowing requests to non-/agentmemory/ routes with the auto-attached Bearer token.

Before submitting, we verified that this isn't blocked by other defenses: the host-header allowlist and CORS restrictions prevent cross-origin attacks but don't prevent a local process from sending raw HTTP requests. The loopback bind prevents remote exploitation but doesn't protect against other processes on the same machine. In non-loopback (Fly) deployments, inbound Bearer is required, which makes the traversal redundant since the attacker would already know the secret.

Proof of Concept

# Precondition: agentmemory running with AGENTMEMORY_SECRET set,
# viewer on default loopback mode (port 3113).
#
# curl normalizes ".." by default, so --path-as-is is needed to
# send the raw traversal path to Node's HTTP server:

curl --path-as-is http://127.0.0.1:3113/agentmemory/../../some-internal-route

# Without the f<!-- -->ix: the viewer passes the startsWith("/agentmemory/")
# check, then fetch() res<!-- -->olves the path to /some-internal-route and
# forwards the request WITH the Bearer token.
#
# With the f<!-- -->ix: the viewer normalizes the path first, sees it res<!-- -->olves
# outside /agentmemory/, and returns HTTP 400.

Node.js's built-in HTTP server passes raw .. segments through in req.url without normalization — this was confirmed experimentally and is documented Node behavior.

Fix Description

The fix adds a normalizeProxyPath() function that:

  1. Parses the raw path using new URL(raw, "http://localhost") — this applies the same URL normalization that fetch() uses internally, resolving all .. segments, percent-encoded variants (%2e%2e, %2E%2E), and backslash tricks.
  2. Checks that the normalized pathname still starts with /agentmemory/.
  3. Returns the normalized path for use in the upstream URL, or null if the path escapes the prefix.

This eliminates the TOCTOU gap: the prefix check and the actual upstream request now operate on the same normalized path. Invalid paths are rejected with HTTP 400 before reaching fetch().

Testing

A new test file test/viewer-proxy-path.test.ts covers 7 scenarios:

Test Input Expected
Literal traversal /agentmemory/../../admin 400, no upstream hit
Percent-encoded %2e%2e /agentmemory/%2e%2e/%2e%2e/admin 400, no upstream hit
Mixed-case %2E%2E /agentmemory/%2E%2E/%2E%2E/admin 400, no upstream hit
Single traversal /agentmemory/../other 400, no upstream hit
Deep traversal /agentmemory/foo/../../bar 400, no upstream hit
Normal path (livez) /agentmemory/livez 200, forwarded correctly
Normal path with query /agentmemory/memories?latest=true 200, forwarded correctly

Each test spins up a recording upstream server and verifies both the HTTP status and that no request reached the upstream for blocked paths.

Files Changed

  • src/viewer/server.ts — Added normalizeProxyPath() and integrated it into proxyToRestApi()
  • test/viewer-proxy-path.test.ts — New test file for path traversal defense

Submitted by Sebastion — autonomous open-source security research from Foundation Machines. Free for public repos via the Sebastion AI GitHub App.

Summary by CodeRabbit

  • Bug Fixes
    • Improved security for the agent memory proxy endpoint by implementing path normalization and validation. The proxy now blocks directory traversal attempts (both raw and encoded variants) and rejects invalid paths with an HTTP 400 error response.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    decision-candidateFork decision has not been madeupstream-openUpstream pull request is openupstream-prTracks an upstream pull request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions