Skip to content

fix(security): prevent path traversal in /assets/ endpoint#40

Open
JulesB40 wants to merge 1 commit intoT3-Content:mainfrom
JulesB40:fix-path-traversal
Open

fix(security): prevent path traversal in /assets/ endpoint#40
JulesB40 wants to merge 1 commit intoT3-Content:mainfrom
JulesB40:fix-path-traversal

Conversation

@JulesB40
Copy link

@JulesB40 JulesB40 commented Feb 23, 2026

Summary

Fixes a path traversal vulnerability in the /assets/ endpoint that could allow reading arbitrary files on the server.

Vulnerability Details

The original code concatenated the URL pathname directly to ./public without validation:

const path = `./public${url.pathname}`;

This allowed attackers to request paths like /assets/../.env or /assets/../server.ts to read sensitive files outside the public directory.

Fix

  • Resolve the path using node:path/resolve
  • Validate that the resolved path starts with the public directory
  • Return 403 Forbidden if the path escapes the intended directory

Testing

Tested the following scenarios:

  • /assets/logo.svg → ✅ Returns file
  • /assets/../server.ts → ✅ Returns 403 Forbidden
  • /assets/..%2Fserver.ts → ✅ Returns 403 Forbidden

Notes

While the live site currently has CDN-level path normalization that mitigates this, the vulnerability exists in the code itself and would be exploitable if:

  • CDN/proxy configuration changes
  • Deployed on different infrastructure
  • Run locally in development mode

Summary by CodeRabbit

  • Bug Fixes
    • Improved asset-serving validation to block attempts to access files outside the public assets directory.
    • Requests that try to traverse outside the public folder are now rejected with a forbidden response.
    • Missing or empty asset requests now return a not-found response, while caching behavior remains unchanged.

@coderabbitai
Copy link

coderabbitai bot commented Feb 23, 2026

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Replaces string-based asset path construction with canonical path resolution against a PUBLIC_DIR, validates resolved paths to prevent directory traversal (returns 403 if outside), and returns 404 for empty asset requests; existing caching headers are preserved.

Changes

Cohort / File(s) Summary
Path Resolution Security
server.ts
Adds resolve/relative usage and PUBLIC_DIR to canonicalize asset requests, rejects traversal outside the public directory with 403, returns 404 for empty asset paths, and serves files from the resolved safe path while keeping caching headers.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Server as Server\n(request handler)
    participant FS as FileSystem\n(PUBLIC_DIR)

    Client->>Server: GET /assets/<path>
    Server->>Server: resolve PUBLIC_DIR + requested path
    Server->>FS: check resolved path location
    alt path outside PUBLIC_DIR
        Server-->>Client: 403 Forbidden
    else empty path
        Server-->>Client: 404 Not Found
    else path inside PUBLIC_DIR
        Server->>FS: read file
        FS-->>Server: file bytes
        Server-->>Client: 200 OK + file + caching headers
    end
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 I hop through PUBLIC_DIR with care,
Resolve my steps, no tricks to snare.
If paths stray off the proper track,
A gentle 403 tells them back. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main security fix: preventing path traversal in the /assets/ endpoint by implementing proper path validation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@macroscopeapp
Copy link

macroscopeapp bot commented Feb 23, 2026

Block path traversal and return 404/403 in /assets/* handling within server.ts to address security in the public asset endpoint

Implements safe path resolution using node:path resolve and relative, introduces PUBLIC_DIR resolved to ./public, returns 404 for empty /assets/ requests, returns 403 when the resolved path escapes PUBLIC_DIR, and serves assets via the resolved absolute path in server.ts.

📍Where to Start

Start in the Bun.serve fetch handler logic for the /assets/ route in server.ts.


Macroscope summarized 307f325.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
server.ts (1)

427-427: Hoist resolve("./public") out of the request handler.

resolve("./public") recomputes the same absolute path (via process.cwd()) on every /assets/ request. Move it to module scope.

♻️ Proposed refactor
+const PUBLIC_DIR = resolve("./public");
+
 const server = Bun.serve<WsData>({
   ...
   async fetch(req, server) {
     ...
     if (url.pathname.startsWith("/assets/")) {
-      const publicDir = resolve("./public");
-      const assetPath = url.pathname.slice(8);
-      const resolved = resolve(publicDir, assetPath);
-      if (!resolved.startsWith(publicDir)) {
+      const assetPath = url.pathname.slice(8);
+      const resolved = resolve(PUBLIC_DIR, assetPath);
+      if (!resolved.startsWith(PUBLIC_DIR + "/") && resolved !== PUBLIC_DIR) {
         return new Response("Forbidden", { status: 403 });
       }
-      const file = Bun.file(resolved);
+      const file = Bun.file(resolved);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.ts` at line 427, The code currently calls resolve("./public") inside
the request handler for /assets/ on every request (assigned to publicDir); hoist
that call to module scope so the absolute path is computed once at load time.
Move the declaration/initialization of publicDir = resolve("./public") out of
the handler function and into the top-level of the module, then update the
handler to reference the module-scoped publicDir variable (look for the request
handler that builds asset paths for "/assets/"). Ensure no other logic relies on
a per-request recomputation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server.ts`:
- Around line 428-429: The code uses assetPath = url.pathname.slice(8) and
resolve(publicDir, assetPath) which makes "/assets/" produce an empty assetPath
and resolve to publicDir (a directory) leading to Bun.file(publicDir) returning
an invalid response; fix by rejecting empty assetPath before resolving (e.g., if
(!assetPath) return a 404) or equivalently check url.pathname === '/assets' ||
url.pathname === '/assets/' and return 404; additionally keep the existing
startsWith/publicDir containment guard to prevent directory traversal.
- Around line 430-432: The guard using resolved.startsWith(publicDir) is unsafe;
replace it with a path.relative-based containment check: compute
relative(publicDir, resolved) (using the already-imported relative) and return
403 if that result starts with '..' or is exactly '..' (i.e., not contained),
otherwise allow; update the check around the resolved/publicDir logic where
resolved and publicDir are used so sibling paths like "/app/public-extra" no
longer pass.

---

Nitpick comments:
In `@server.ts`:
- Line 427: The code currently calls resolve("./public") inside the request
handler for /assets/ on every request (assigned to publicDir); hoist that call
to module scope so the absolute path is computed once at load time. Move the
declaration/initialization of publicDir = resolve("./public") out of the handler
function and into the top-level of the module, then update the handler to
reference the module-scoped publicDir variable (look for the request handler
that builds asset paths for "/assets/"). Ensure no other logic relies on a
per-request recomputation.

The /assets/ endpoint was vulnerable to path traversal attacks that could allow
reading arbitrary files on the server (e.g., /assets/../.env, /assets/../server.ts).

This fix resolves the path and validates that it remains within the public directory
before serving the file. Returns 403 Forbidden if path escapes the intended directory.
@nmggithub
Copy link

Duplicate of #6 (or, rather, #6 covers this and more). Also, while the concatenation isn't ideal, it's not actually vulnerable to path traversal. The line above that wraps the incoming URL in a new URL, actually does mitigate the use of any usage of ...

const url = new URL(req.url);

For context, see the WHATWG URL specification. Any .. (or even .) path segments will be parsed and removed by that line. So anything coming out of .pathname from that URL, won't contain any ..s or .s with which to perform path traversal.

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.

2 participants