Skip to content

fix(image): prevent path traversal in /api/image endpoint#865

Open
sebastiondev wants to merge 1 commit into
backnotprop:mainfrom
sebastiondev:fix/cwe22-image-file-7a3a
Open

fix(image): prevent path traversal in /api/image endpoint#865
sebastiondev wants to merge 1 commit into
backnotprop:mainfrom
sebastiondev:fix/cwe22-image-file-7a3a

Conversation

@sebastiondev

Copy link
Copy Markdown

Vulnerability Summary

The /api/image endpoint in both packages/server/image.ts and apps/pi-extension/server/handlers.ts is vulnerable to path traversal (CWE-22). The path query parameter is user-controlled and is resolved to an absolute filesystem path using resolve(), but the only validation performed is an image-extension check. An attacker can supply a traversal payload (e.g., ../../../etc/hostname.svg or an absolute path like /home/user/.ssh/id_rsa.png) to read any file on the filesystem, as long as it has a recognized image extension.

Affected files:

  • packages/server/image.tsvalidateImagePath() (used by shared-handlers.ts which serves all plan/review/annotate servers)
  • apps/pi-extension/server/handlers.ts — duplicate validateImagePath() for the PI extension servers

Data flow:

  1. User sends GET /api/image?path=../../../etc/hostname.svg
  2. url.searchParams.get("path") extracts the raw path — no sanitization
  3. validateImagePath() calls resolve(rawPath) → produces an absolute path
  4. Extension check passes (.svg is allowed)
  5. Bun.file(resolved) / readFileSync(resolved) reads and returns the file contents

The base query parameter follows the same vulnerable flow — resolvePath(base, imagePath) can also escape the project directory.

Risk Assessment

Severity: Medium

The vulnerability is constrained to files with image extensions (.png, .jpg, .svg, .gif, etc.), which limits the blast radius. However:

  • .svg files are plain-text XML and can contain arbitrary content. If an SVG file exists outside the project root (config templates, exported data, etc.), its contents are fully readable.
  • In remote sessions (SSH, PLANNOTATOR_REMOTE=1), the server binds to 0.0.0.0 — making the unauthenticated /api/image endpoint network-accessible.
  • Shared plans can embed malicious image references. When a victim opens a shared plan, the markdown renderer generates /api/image?path=../../other-project/secret.svg requests, reading files from the victim's filesystem.
  • There is no authentication on any endpoint (confirmed by the codebase's own agent instruction docs: "No authentication").

Proof of Concept

Start any Plannotator server (plan, review, or annotate). Then:

# Read a file outside the project root — any file with an image extension.
# For example, if /etc/hostname.svg existed:
curl "http://localhost:<PORT>/api/image?path=../../../etc/hostname.svg"

# Using an absolute path:
curl "http://localhost:<PORT>/api/image?path=/tmp/outside-project/data.png"

# Using the 'base' parameter to escape:
curl "http://localhost:<PORT>/api/image?path=secret.png&base=/etc"

In each case, the server returns the file contents with a 200 response. Without this fix, validateImagePath() only checks the extension — any path that resolves to an image-extension file is served regardless of location.

You can verify the vulnerability by placing a test file outside the project directory:

echo "LEAKED" > /tmp/test-traversal.svg
curl "http://localhost:<PORT>/api/image?path=/tmp/test-traversal.svg"
# Returns: LEAKED

After applying this fix, the same requests return 403 Access denied: path is outside project root.

Fix Description

This PR adds an isWithinAllowedRoot() containment check to validateImagePath() in both server implementations. The function:

  1. Resolves and normalizes the requested path
  2. Normalizes the project root (process.cwd()) and the upload temp directory (UPLOAD_DIR)
  3. Verifies the resolved path starts with one of these two allowed roots (using exact prefix matching with trailing / to prevent prefix-bypass attacks like /project-root-evil/...)

The check is added to validateImagePath() itself, so it protects both the primary path parameter and the fallback base parameter resolution — every code path that serves images goes through this function.

Files changed:

  • packages/server/image.ts — added isWithinAllowedRoot() and integrated it into validateImagePath()
  • apps/pi-extension/server/handlers.ts — same change for the PI extension's copy of the validation logic
  • packages/server/image.test.ts — added test cases for traversal rejection (absolute paths, ../ sequences, paths outside project root) and updated existing tests to use project-relative paths

Test Results

The existing test suite was extended with three new path-traversal test cases. All tests pass:

  • accepts supported extensions within project root — image files inside the project are still served
  • accepts images within UPLOAD_DIR — uploaded temp files are still accessible
  • rejects unsupported extensions — non-image files still rejected
  • rejects files with no extension — extensionless files still rejected
  • resolves path — relative paths resolve to absolute (existing behavior preserved)
  • rejects path traversal outside project root/etc/passwd.png → 403
  • rejects ../ traversal escaping project root../../../etc/secret.png → 403
  • rejects absolute path outside project root/tmp/outside/image.png → 403

Adversarial Review

Before submitting, we attempted to disprove this finding. We checked whether any framework-level middleware, authentication gate, or path sanitization existed upstream of validateImagePath(). There is none — the codebase's own documentation confirms "No authentication" on all endpoints. We also considered whether the image-extension restriction alone was sufficient mitigation; it is not, because .svg files are plain-text XML and can contain sensitive data. The base parameter could also be used to pivot the resolve root, but the fix covers that path too since validateImagePath() is called on the resolved result.


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

validateImagePath() only checked file extensions but did not verify the
resolved path stays within the project root or upload directory. This
allowed reading arbitrary image files from anywhere on the filesystem
via GET /api/image?path=<traversal>.

Add isWithinAllowedRoot() containment check that ensures the resolved
path is within either process.cwd() (project root) or the UPLOAD_DIR
(temp uploads). Applied to both packages/server/image.ts and the
duplicated handler in apps/pi-extension/server/handlers.ts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant