Skip to content

fix(api): authenticated file endpoint reflects any origin with credentials, enabling cross-origin theft of private user files - #2291

Draft
elberacasa wants to merge 1 commit into
refly-ai:mainfrom
elberacasa:fix/misc-static-cors-credentials
Draft

fix(api): authenticated file endpoint reflects any origin with credentials, enabling cross-origin theft of private user files#2291
elberacasa wants to merge 1 commit into
refly-ai:mainfrom
elberacasa:fix/misc-static-cors-credentials

Conversation

@elberacasa

@elberacasa elberacasa commented Aug 4, 2026

Copy link
Copy Markdown

Summary

The authenticated file endpoint GET /v1/misc/static/:objectKey (guarded by JwtAuthGuard, serving users' private files after an ownership check) reflects the request Origin header into Access-Control-Allow-Origin and simultaneously sends Access-Control-Allow-Credentials: true.

Because Refly sessions are stored in cookies, this is exploitable today: any website can make a logged-in victim's browser fetch their private files cross-origin and read the responses. No clicks, no warnings, no visible trace for the victim. Loading an attacker page is enough.

Impact

  • Silent theft of private user content. Uploads and generated artifacts belonging to any logged-in user can be read by any site the victim visits: a phishing page, a malicious ad, a compromised third party.
  • Scale. If objectKey values are enumerable or leak through any listing endpoint, an attacker can harvest files from every victim who loads their page, not just one.
  • Blast radius. Every deployment of this code is affected, including self-hosted instances.

Cross-Origin-Resource-Policy: cross-origin additionally allows the protected content to be embedded directly into attacker pages, independent of the CORS issue.

Proof of concept

From any origin, while the victim is logged in:

fetch('https://<refly-instance>/v1/misc/static/<objectKey>', { credentials: 'include' })
  .then((r) => r.text())
  .then((content) => {
    // content is the victim's private file, readable cross-origin
  });

The browser attaches the session cookie, the server reflects the attacker's origin with credentials allowed, and the response is readable. Identified with umbra, an open source security scanner I maintain, then verified by manual code review against main. I did not test against any live deployment.

Root cause

CORS safety depends on never combining a reflected untrusted origin with credentials. With both present, the browser sends the victim's cookies and exposes the response body to the attacker's JavaScript. The ownership check still passes because the request is legitimately authenticated as the victim.

Notably, the application already does this correctly at the global level: main.ts configures CORS with a fixed allowlist from the ORIGIN env var. This endpoint overrode that policy with unconditional reflection.

Fix

Only grant credentialed CORS to origins in the existing ORIGIN allowlist, the same configuration the global middleware uses:

  • Allowlisted origins keep working exactly as before (Access-Control-Allow-Origin + credentials + Vary: Origin).
  • All other origins receive no Access-Control-Allow-Origin, so browsers block cross-origin reads of authenticated responses.
  • Cross-Origin-Resource-Policy: cross-origin is preserved, so legitimate no-cors embedding (images and similar) keeps working.
  • The unauthenticated servePublicStatic endpoint is intentionally unchanged: it serves public data, and tightening it could break third-party consumers.

Tests

Added regression tests covering allowlisted origins (credentialed access preserved), unknown origins (no CORS headers), and requests without an Origin header. All pass: npx jest src/modules/misc/misc.controller.spec.ts, 4/4.

The controller spec also needed stale mocks refreshed (ApiKeyService, PrismaService), since JwtAuthGuard gained dependencies after the spec was written. Without that, the suite could not run at all.

Related hardening notes (out of scope here)

  1. CustomThrottlerGuard.shouldSkip in apps/api/src/modules/app.module.ts disables rate limiting whenever NODE_ENV !== 'production'. A deployment missing that variable silently loses brute-force protection on auth endpoints.
  2. deploy/searxng/settings.yml commits a fixed 64-hex SearXNG secret_key mounted by the default compose setup, so default self-hosted installs share the same HMAC secret.

Happy to split those into follow-ups if you want them addressed.

Summary by CodeRabbit

  • Bug Fixes
    • Improved cross-origin access controls for authenticated static-file requests.
    • Credentialed requests are now limited to configured, approved origins instead of allowing unrestricted origins.
    • Added handling for requests from unapproved or missing origins.

…nticated file endpoint

GET /v1/misc/static/:objectKey is guarded by JwtAuthGuard and serves
users' private files, but reflected the request Origin into
Access-Control-Allow-Origin while sending
Access-Control-Allow-Credentials: true. With cookie-based sessions, any
website could read a logged-in user's private files cross-origin.

Only grant credentialed CORS to origins in the ORIGIN allowlist (the
same configuration the global CORS middleware in main.ts uses). Other
origins receive no Access-Control-Allow-Origin, so browsers block
cross-origin reads, while Cross-Origin-Resource-Policy: cross-origin
keeps legitimate no-cors embedding working.

Adds regression tests for allowlisted, unknown, and missing origins.
Also updates the stale controller spec mocks (ApiKeyService,
PrismaService) required by the current JwtAuthGuard.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

MiscController now restricts credentialed CORS headers to configured origins for authenticated static-file responses. Tests cover allowed, rejected, and missing origins.

Changes

CORS header control

Layer / File(s) Summary
Allowlisted CORS header implementation
apps/api/src/modules/misc/misc.controller.ts
MiscController injects ConfigService and generates CORS headers from the configured comma-separated origin allowlist. The authenticated static-file handler uses these headers instead of permissive wildcard credentials.
CORS header test coverage
apps/api/src/modules/misc/misc.controller.spec.ts
The test module registers ApiKeyService and PrismaService mocks. Tests cover allowlisted origins, rejected origins, resource policy headers, and requests without an Origin header.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the authenticated file endpoint CORS vulnerability and the resulting cross-origin exposure of private files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/api/src/modules/misc/misc.controller.ts (1)

50-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Define constants for fixed CORS strings.

'origin', header names, and fixed header values are new magic strings. Define named constants for these values so the CORS contract has one maintainable definition.

As per coding guidelines: “Avoid magic numbers and strings - use named constants in TypeScript/JavaScript.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/misc/misc.controller.ts` around lines 50 - 62, Define
named constants for the configuration key `'origin'`, the CORS header names, and
fixed header values used by the allowed-origin logic in the controller. Update
the `allowedOrigins` lookup and `headers` construction to reference those
constants while preserving the existing CORS behavior.

Source: Coding guidelines

apps/api/src/modules/misc/misc.controller.spec.ts (1)

39-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test the handler response without any.

(controller as any) bypasses TypeScript checks and tests a private implementation detail. Test serveStatic with mocked request and response objects. Cover both the 200 and 304 paths. This verifies that the CORS headers reach the HTTP response.

As per coding guidelines: “Avoid using any type whenever possible - use unknown type instead with proper type guards” and “Always define explicit return types for functions, especially for public APIs.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/misc/misc.controller.spec.ts` around lines 39 - 40,
Replace the buildCorsHeaders helper in the misc controller spec with tests that
invoke serveStatic using mocked request and response objects, avoiding any and
private-method access. Assert both 200 and 304 response paths, including that
the generated CORS headers are applied to the HTTP response, and give all test
helpers explicit return types.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/modules/misc/misc.controller.ts`:
- Around line 55-63: Initialize headers with Vary: Origin before the origin
allowlist conditional so every response, including rejected- and missing-origin
cases, carries it. Keep the existing allowed-origin headers unchanged, and
update the corresponding rejected-origin and missing-origin tests to assert the
Vary header.

---

Nitpick comments:
In `@apps/api/src/modules/misc/misc.controller.spec.ts`:
- Around line 39-40: Replace the buildCorsHeaders helper in the misc controller
spec with tests that invoke serveStatic using mocked request and response
objects, avoiding any and private-method access. Assert both 200 and 304
response paths, including that the generated CORS headers are applied to the
HTTP response, and give all test helpers explicit return types.

In `@apps/api/src/modules/misc/misc.controller.ts`:
- Around line 50-62: Define named constants for the configuration key
`'origin'`, the CORS header names, and fixed header values used by the
allowed-origin logic in the controller. Update the `allowedOrigins` lookup and
`headers` construction to reference those constants while preserving the
existing CORS behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 121f07a2-3e04-4255-ac0d-b5e73247a0ea

📥 Commits

Reviewing files that changed from the base of the PR and between 71f8b87 and b05de2b.

📒 Files selected for processing (2)
  • apps/api/src/modules/misc/misc.controller.spec.ts
  • apps/api/src/modules/misc/misc.controller.ts

Comment on lines +55 to +63
const headers: Record<string, string> = {
'Cross-Origin-Resource-Policy': 'cross-origin',
};

if (origin && allowedOrigins.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Credentials'] = 'true';
headers['Vary'] = 'Origin';
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Set Vary: Origin for all responses.

This response varies by request Origin, but rejected-origin responses omit Vary: Origin. A cache can store that variant as origin-independent and reuse it for an allowlisted origin. Set Vary: Origin in headers before the conditional. Add assertions for Vary in the rejected-origin and missing-origin tests.

Proposed fix
     const headers: Record<string, string> = {
       'Cross-Origin-Resource-Policy': 'cross-origin',
+      'Vary': 'Origin',
     };
 
     if (origin && allowedOrigins.includes(origin)) {
       headers['Access-Control-Allow-Origin'] = origin;
       headers['Access-Control-Allow-Credentials'] = 'true';
-      headers['Vary'] = 'Origin';
     }
📝 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
const headers: Record<string, string> = {
'Cross-Origin-Resource-Policy': 'cross-origin',
};
if (origin && allowedOrigins.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Credentials'] = 'true';
headers['Vary'] = 'Origin';
}
const headers: Record<string, string> = {
'Cross-Origin-Resource-Policy': 'cross-origin',
'Vary': 'Origin',
};
if (origin && allowedOrigins.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Credentials'] = 'true';
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/misc/misc.controller.ts` around lines 55 - 63,
Initialize headers with Vary: Origin before the origin allowlist conditional so
every response, including rejected- and missing-origin cases, carries it. Keep
the existing allowed-origin headers unchanged, and update the corresponding
rejected-origin and missing-origin tests to assert the Vary header.

@elberacasa

Copy link
Copy Markdown
Author

Update: reproduced end-to-end against the published Docker images.

I spun up the stock self-hosted stack (deploy/docker/docker-compose.yml, images reflyai/refly-api:latest sha256:f75324a3 and reflyai/refly-web:latest sha256:4f64af9e, default env.example config) and confirmed the exploit works exactly as described, both with curl and from a real browser session.

1. Victim signs up, uploads a private file

curl -c cookies.txt -X POST http://localhost:5700/api/v1/auth/email/signup \
  -H 'Content-Type: application/json' \
  -d '{"email":"victim@example.com","password":"..."}'
curl -c cookies.txt -X POST http://localhost:5700/api/v1/auth/email/login -d '{...same...}'
# -> Set-Cookie: _rf_access=<JWT>; HttpOnly; Secure; SameSite=Lax

curl -b cookies.txt -X POST http://localhost:5700/api/v1/misc/upload \
  -F "file=@secret.txt;type=text/plain" -F "visibility=private"
# -> {"storageKey":"static/05f79648-....txt"}

2. Attacker origin reads the private file

curl -b cookies.txt -H 'Origin: https://evil.example.com' -i \
  http://localhost:5700/api/v1/misc/static/05f79648-c41a-4cd4-aaa5-14282e88821e.txt
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://evil.example.com

REFLY-PRIVATE-SECRET: top-secret launch codes 42

Any origin string is reflected. No preflight is even needed, since this is a CORS "simple request": the response headers alone authorize the cross-origin read.

3. Browser confirmation

I logged a browser into the local instance, then served an attacker page from a different origin whose only code is:

fetch('http://localhost:5700/api/v1/misc/static/05f79648-....txt', { credentials: 'include' })
  .then(r => r.text()) // resolves to the victim's private file body

The page rendered HTTP 200 plus the full private file contents. (Cross-port localhost is same-site, so the SameSite=Lax session cookie is attached; the only thing that could stop the read is CORS, and CORS grants it.)

Controls

  • No cookies, attacker origin: 401 Unauthorized. The endpoint is properly authenticated; only the CORS reflection is broken.
  • The same request without an Origin header falls back to Access-Control-Allow-Origin: *, which browsers ignore for credentialed requests. Only the reflected-origin path is exploitable.

On this PR's branch, step 2 and 3 fail the CORS check because evil.example.com is not in the ORIGIN allowlist, while the legitimate first-party origin keeps working. Unit tests for all three cases are included in the diff.

@elberacasa
elberacasa marked this pull request as draft August 10, 2026 14:05
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