Skip to content

Harden build upload proxy against path traversal#1764

Open
riderx wants to merge 3 commits intomainfrom
riderx/fix-tus-path-validation
Open

Harden build upload proxy against path traversal#1764
riderx wants to merge 3 commits intomainfrom
riderx/fix-tus-path-validation

Conversation

@riderx
Copy link
Member

@riderx riderx commented Mar 9, 2026

Summary (AI generated)

  • Rehardened the build upload proxy path handling to validate decoded path segments safely, reject null-byte and directory traversal inputs, and prevent path escape outside the /upload namespace.
  • Preserved encoded TUS resource paths when forwarding to the builder by resolving against a canonical upload base URL instead of rebuilding from decoded suffixes.
  • Added/updated concurrent backend tests for traversal and bad-encoding rejection and positive forwarding behavior of canonical upload suffixes.

Motivation (AI generated)

  • The proxy previously performed suffix concatenation and normalization in ways that could allow encoded path manipulation and accidental traversal, weakening the trust boundary to internal builder endpoints.
  • The security review requested more strict segment-based validation while keeping backward-compatible resource identity for valid encoded TUS paths.

Business Impact (AI generated)

  • Reduces the risk of privileged internal builder endpoint access through upload-proxy path abuse.
  • Preserves existing TUS resumable upload behavior for clients using encoded path segments while preventing unauthorized request routing.

Test Plan (AI generated)

  • bun lint
  • bun lint:backend
  • bunx eslint supabase/functions/_backend/public/build/upload.ts tests/build-upload-security.test.ts

Summary by CodeRabbit

  • Bug Fixes

    • Improved validation of file upload paths to prevent invalid requests and provide clearer error messages.
    • Enhanced error handling for malformed upload paths and invalid URL encoding.
  • Tests

    • Added comprehensive test coverage for upload validation and security logic.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

The Tus upload proxy implements URL parsing, path decoding, and path traversal protection by validating against null characters and path segment traversal (e.g., ., ..). The handler resolves paths against an upload directory base URL and rejects escape attempts. A comprehensive test suite validates the security measures against traversal and encoding attacks.

Changes

Cohort / File(s) Summary
Upload Proxy Security
supabase/functions/_backend/public/build/upload.ts
Enhanced URL parsing from request pathname, added decoding with invalid encoding error handling, implemented path traversal protection (null chars, ., .. segments), URL resolution with escape validation, and improved error logging for path validation failures.
Security Test Suite
tests/build-upload-security.test.ts
New test suite verifying path traversal rejection, invalid encoding rejection, and valid upload forwarding to builder service; includes mocked environment, RBAC checks, and fetch assertions.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Handler as Upload Handler
    participant Parser as URL Parser
    participant Validator as Path Validator
    participant Resolver as URL Resolver
    participant Builder as Builder Service
    participant Response as Response Handler

    Client->>Handler: POST /upload/path
    Handler->>Parser: Parse request URL pathname
    Parser->>Parser: Extract originalPath
    Parser->>Validator: Decode tusPath with decodeURIComponent
    
    alt Invalid Encoding
        Validator->>Handler: Throw 400 invalid_path
        Handler->>Client: 400 Bad Request
    else Valid Encoding
        Validator->>Validator: Check for null chars, '.', '..' segments
        alt Path Traversal Detected
            Validator->>Handler: Throw 400 invalid_path
            Handler->>Client: 400 Bad Request
        else Valid Path
            Validator->>Resolver: Provide validated path
            Resolver->>Resolver: Construct base upload URL
            Resolver->>Resolver: Resolve relative tusPath
            Resolver->>Resolver: Validate no escape outside directory
            
            alt Escape Detected
                Resolver->>Handler: Throw 400 invalid_path
                Handler->>Client: 400 Bad Request
            else Safe Path
                Resolver->>Handler: Return safeTusPath & resolvedURL
                Handler->>Builder: Forward request with x-api-key header
                Builder->>Builder: Process upload
                Builder-->>Handler: 201 Created + Location header
                Handler->>Response: Rewrite Location header if needed
                Response-->>Client: 201 Created
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Paths once twisted, now stand tall,
With segments checked and traversals squashed,
URL-decoded, validated all,
The burrow's treasure safely stashed! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Harden build upload proxy against path traversal' accurately summarizes the main change: security hardening of the upload proxy to prevent path traversal attacks.
Description check ✅ Passed The PR description includes a Summary section with the main changes, Motivation explaining the rationale, Business Impact, and Test Plan with completed checks. However, it lacks a formal 'Test plan' section with reproducible testing steps and 'Screenshots' section as specified in the template.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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
  • Commit unit tests in branch riderx/fix-tus-path-validation

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

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 96040f4b9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +80 to +82
afterEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()

Choose a reason for hiding this comment

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

P2 Badge Avoid resetting global mocks during concurrent cases

These tests run with it.concurrent(...) but share module-level mocks, and this afterEach calls vi.restoreAllMocks()/vi.clearAllMocks() globally; when one test finishes first, it can restore mocks that another concurrent test is still using (notably the globalThis.fetch spy in the success case), creating nondeterministic CI failures or unexpected real network calls. Either run this suite serially or isolate mock state per test without global restore in concurrent mode.

Useful? React with 👍 / 👎.

Copy link
Contributor

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/build-upload-security.test.ts`:
- Around line 85-109: The test currently captures the error with .catch()
causing TypeScript to infer a union (Response | HTTPException) and preventing
property access; change each test to await tusProxy(...) inside a try/catch:
call await tusProxy(context as any, jobId, ...) in the try and force a test
failure if it resolves, then in the catch block assert
expect(err).toBeInstanceOf(HTTPException), cast err to HTTPException (e.g. const
e = err as HTTPException) and then check e.cause with
expect(e.cause).toMatchObject(...); this uses tusProxy, HTTPException,
fakeContext and jobId to locate the code to update.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e68f3e7-06f4-4210-9241-c29a221f58dc

📥 Commits

Reviewing files that changed from the base of the PR and between 2af3a08 and 96040f4.

📒 Files selected for processing (2)
  • supabase/functions/_backend/public/build/upload.ts
  • tests/build-upload-security.test.ts

Comment on lines +85 to +109
it.concurrent('rejects path traversal attempts before forwarding to builder', async () => {
const context = fakeContext(`http://localhost/build/upload/${jobId}/%2e%2e/jobs`, 'PATCH')

const error = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
.catch(err => err as HTTPException)

expect(error).toBeInstanceOf(HTTPException)
expect(error.cause).toMatchObject({
error: 'invalid_path',
message: 'Invalid upload path',
})
})

it.concurrent('rejects invalidly encoded paths before forwarding to builder', async () => {
const context = fakeContext(`http://localhost/build/upload/${jobId}/%`, 'PATCH')

const error = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
.catch(err => err as HTTPException)

expect(error).toBeInstanceOf(HTTPException)
expect(error.cause).toMatchObject({
error: 'invalid_path',
message: 'Invalid upload path encoding.',
})
})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix TypeScript type narrowing for pipeline to pass.

The pipeline fails because tusProxy returns Promise<Response>, so the .catch() result type is Response | HTTPException. TypeScript doesn't narrow after expect().toBeInstanceOf(), causing TS2339 on .cause access.

🐛 Proposed fix: restructure assertions for proper type narrowing
-  it.concurrent('rejects path traversal attempts before forwarding to builder', async () => {
-    const context = fakeContext(`http://localhost/build/upload/${jobId}/%2e%2e/jobs`, 'PATCH')
-
-    const error = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
-      .catch(err => err as HTTPException)
-
-    expect(error).toBeInstanceOf(HTTPException)
-    expect(error.cause).toMatchObject({
-      error: 'invalid_path',
-      message: 'Invalid upload path',
-    })
-  })
-
-  it.concurrent('rejects invalidly encoded paths before forwarding to builder', async () => {
-    const context = fakeContext(`http://localhost/build/upload/${jobId}/%`, 'PATCH')
-
-    const error = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
-      .catch(err => err as HTTPException)
-
-    expect(error).toBeInstanceOf(HTTPException)
-    expect(error.cause).toMatchObject({
-      error: 'invalid_path',
-      message: 'Invalid upload path encoding.',
-    })
-  })
+  it.concurrent('rejects path traversal attempts before forwarding to builder', async () => {
+    const context = fakeContext(`http://localhost/build/upload/${jobId}/%2e%2e/jobs`, 'PATCH')
+
+    const result = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
+      .catch(err => err)
+
+    expect(result).toBeInstanceOf(HTTPException)
+    if (result instanceof HTTPException) {
+      expect(result.cause).toMatchObject({
+        error: 'invalid_path',
+        message: 'Invalid upload path',
+      })
+    }
+  })
+
+  it.concurrent('rejects invalidly encoded paths before forwarding to builder', async () => {
+    const context = fakeContext(`http://localhost/build/upload/${jobId}/%`, 'PATCH')
+
+    const result = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
+      .catch(err => err)
+
+    expect(result).toBeInstanceOf(HTTPException)
+    if (result instanceof HTTPException) {
+      expect(result.cause).toMatchObject({
+        error: 'invalid_path',
+        message: 'Invalid upload path encoding.',
+      })
+    }
+  })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it.concurrent('rejects path traversal attempts before forwarding to builder', async () => {
const context = fakeContext(`http://localhost/build/upload/${jobId}/%2e%2e/jobs`, 'PATCH')
const error = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
.catch(err => err as HTTPException)
expect(error).toBeInstanceOf(HTTPException)
expect(error.cause).toMatchObject({
error: 'invalid_path',
message: 'Invalid upload path',
})
})
it.concurrent('rejects invalidly encoded paths before forwarding to builder', async () => {
const context = fakeContext(`http://localhost/build/upload/${jobId}/%`, 'PATCH')
const error = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
.catch(err => err as HTTPException)
expect(error).toBeInstanceOf(HTTPException)
expect(error.cause).toMatchObject({
error: 'invalid_path',
message: 'Invalid upload path encoding.',
})
})
it.concurrent('rejects path traversal attempts before forwarding to builder', async () => {
const context = fakeContext(`http://localhost/build/upload/${jobId}/%2e%2e/jobs`, 'PATCH')
const result = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
.catch(err => err)
expect(result).toBeInstanceOf(HTTPException)
if (result instanceof HTTPException) {
expect(result.cause).toMatchObject({
error: 'invalid_path',
message: 'Invalid upload path',
})
}
})
it.concurrent('rejects invalidly encoded paths before forwarding to builder', async () => {
const context = fakeContext(`http://localhost/build/upload/${jobId}/%`, 'PATCH')
const result = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any)
.catch(err => err)
expect(result).toBeInstanceOf(HTTPException)
if (result instanceof HTTPException) {
expect(result.cause).toMatchObject({
error: 'invalid_path',
message: 'Invalid upload path encoding.',
})
}
})
🧰 Tools
🪛 GitHub Actions: Run tests

[error] 92-92: TypeScript error TS2339: Property 'cause' does not exist on type 'Response | HTTPException'. Property 'cause' does not exist on type 'Response'.


[error] 105-105: TypeScript error TS2339: Property 'cause' does not exist on type 'Response | HTTPException'. Property 'cause' does not exist on type 'Response'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/build-upload-security.test.ts` around lines 85 - 109, The test
currently captures the error with .catch() causing TypeScript to infer a union
(Response | HTTPException) and preventing property access; change each test to
await tusProxy(...) inside a try/catch: call await tusProxy(context as any,
jobId, ...) in the try and force a test failure if it resolves, then in the
catch block assert expect(err).toBeInstanceOf(HTTPException), cast err to
HTTPException (e.g. const e = err as HTTPException) and then check e.cause with
expect(e.cause).toMatchObject(...); this uses tusProxy, HTTPException,
fakeContext and jobId to locate the code to update.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 9, 2026

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