Skip to content

fix: serve public assets on self-hosted deployments - #2266

Open
fancyHeat wants to merge 4 commits into
CapSoftware:mainfrom
fancyHeat:fix/self-hosted-public-assets
Open

fix: serve public assets on self-hosted deployments#2266
fancyHeat wants to merge 4 commits into
CapSoftware:mainfrom
fancyHeat:fix/self-hosted-public-assets

Conversation

@fancyHeat

@fancyHeat fancyHeat commented Sep 10, 2026

Copy link
Copy Markdown

Problem

On a self-hosted deployment (NEXT_PUBLIC_IS_CAP unset), proxy.ts redirects every path outside its allowlist to /login, and the matcher only exempts favicon.ico, robots.txt and sitemap.xml. Every other file under apps/web/public/ therefore 307s to /login, so <img src="/logos/browsers/google-chrome.svg"> on onboarding, the OS/browser logos, illustrations, sounds, Rive files and fonts all render broken. cap.so never sees this because it takes the IS_CAP branch.

Reproduced on a Docker build of 24e3c74:

GET /logos/browsers/google-chrome.svg -> 307 /login
GET /logos/cap.svg                    -> 307 /login
GET /icons/logo.png                   -> 307 /login
GET /favicon.ico                      -> 200

Fix

In the self-hosted branch, let a path through when it resolves to an existing file under apps/web/public/. The proxy already runs in the Node runtime (it imports the database), so this is a statSync on a resolved path, with traversal out of public/ rejected.

This avoids maintaining an extension list, which is where #2127 was flagged for still missing .riv and the recorder sounds, and it does not widen the bypass to non-asset routes: /install-cli.sh and the docs catch-all keep their current behaviour because they are not files in public/.

Related: #2127 takes the extension-allowlist approach for the same bug.

Tests

apps/web/__tests__/unit/proxy-self-hosted.test.ts now calls proxy() directly with NEXT_PUBLIC_IS_CAP mocked off and asserts:

  • real .svg, .webp, .ogg, .riv, .woff2 and root-level site.webmanifest assets return 200 with no redirect
  • /pricing still redirects to /login
  • /install-cli.sh, an extension-suffixed route handler, still redirects
  • a missing file, a directory, and ..%2F traversal all still redirect
  • /s/video123 is unaffected

Confirmed the asset cases fail on main and pass with the change.

Validation

bun run biome check --write apps/web/proxy.ts apps/web/__tests__/unit/proxy-self-hosted.test.ts
cd apps/web && bun run vitest run __tests__/unit/proxy-self-hosted.test.ts __tests__/unit/share-iframe-navigation.test.ts

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the current implementation addresses all previous findings without introducing a new actionable defect.

Findings

  1. P1 Extensionless Asset Still Redirects
  2. P2 Malformed Paths Can Throw
  3. P2 Filesystem Errors Escape Proxy
Fix with agent prompt
### Issue 1
apps/web/proxy.ts:undefined-59
The extension-only check does not match the existing public file `public/.well-known/atproto-did`, and the separate allowlist only covers `/.well-known/workflow/`. On self-hosted deployments, requests for this well-known resource therefore still redirect to `/login` instead of serving the file, leaving the public-asset fix incomplete.

### Issue 2
apps/web/proxy.ts:undefined-27
A request path containing an encoded NUL byte, such as `/%00`, is decoded and passed to `statSync`. Node rejects that path with `ERR_INVALID_ARG_VALUE`, which `throwIfNoEntry: false` does not suppress. Because this call is outside the later error handler, the self-hosted proxy throws instead of redirecting the request to `/login`. Catch filesystem validation errors and treat them as a non-asset path.

### Issue 3
apps/web/proxy.ts:undefined-27
A request pathname can make `statSync` fail for reasons other than a missing entry-for example, an overlong decoded path can produce `ENAMETOOLONG`. Since this call is outside a catch block, the error escapes `proxy()` and returns a 500 instead of following the existing `/login` redirect behavior. Treat filesystem lookup failures as “not a public asset.”

```suggestion
	try {
		return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
	} catch {
		return false;
	}
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

  • Uses canonical path resolution and a public-directory boundary check to reject traversal.
  • Treats malformed paths and filesystem lookup failures as non-assets.
  • Adds direct proxy tests for common asset formats, an extensionless well-known file, route preservation, traversal, directories, missing files, and malformed paths.
  • The three previous findings are fully addressed by the current implementation and regression coverage.

Reviews (4) · Last reviewed commit: "fix: treat filesystem lookup failures as..."

When NEXT_PUBLIC_IS_CAP is not "true", proxy.ts redirects every path outside
its allowlist to /login, and the matcher only exempts favicon.ico, robots.txt
and sitemap.xml. Every other file under apps/web/public therefore 307s to
/login on a self-hosted instance and renders broken.

Let any path with a file extension through the self-hosted redirect. Such a
path is either a static file or a 404, never a page, so the login gate has
nothing to protect there, and new asset types work without a matcher edit.

@superagent-security superagent-security 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.

Superagent found 1 security concern(s).

Comment thread apps/web/proxy.ts Outdated
const isStaticAsset = /\.[a-z0-9]+$/i.test(path);
if (
!(
isStaticAsset ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The extension check can bypass the self-hosted authentication gate for non-static routes

Any URL ending in an extension bypasses login; the code does not verify that it is an actual public asset.

Scope the bypass to verified public assets and test a protected extension-suffixed route.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="apps/web/proxy.ts">
<violation number="1" location="apps/web/proxy.ts:62">
<priority>P2</priority>
<title>The extension check can bypass the self-hosted authentication gate for non-static routes</title>
<evidence>The new `isStaticAsset = /\.[a-z0-9]+$/i.test(path)` condition is sufficient to skip the login redirect, but it does not verify that the path resolves to a file under `public/`. Any protected page, API endpoint, or dynamic route whose URL ends in an extension can therefore be allowed through. The added tests cover real assets and `/pricing`, but do not cover an extension-suffixed protected route.</evidence>
<recommendation>Restrict the bypass to paths known to be public assets (for example, an explicit public-asset path policy or narrowly scoped asset directories) rather than inferring static-file status solely from the URL suffix. Add a regression test for a protected route ending in `.json`, `.csv`, or another extension.</recommendation>
</violation>
</file>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair point, there are extension-suffixed routes (install-cli.sh, the docs catch-all). Reworked: the bypass now requires the path to resolve to an existing file under public/, with traversal out of the directory rejected. Added tests for /install-cli.sh, a missing file, a directory, and ..%2F traversal, all of which still redirect.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The developer replaced the broad extension-suffix bypass with a filesystem-level verification that the path resolves to an existing file under public/ while rejecting directory traversal, and added regression tests for extension-suffixed routes, missing files, directories, and encoded traversal. This directly resolves the finding and satisfies the recommendation.

An extension alone is not proof of a static asset: install-cli.sh and the
docs catch-all are extension-suffixed routes. Resolve the path under public/
and require it to be an existing file, rejecting traversal out of the
directory.
Comment thread apps/web/proxy.ts Outdated
if (buildEnv.NEXT_PUBLIC_IS_CAP !== "true") {
// Files under public/ have no route of their own, so without this every
// <img src="/logos/..."> on a self-hosted instance redirects to /login.
const isStaticAsset = /\.[a-z0-9]+$/i.test(path);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Extensionless Asset Still Redirects

The extension-only check does not match the existing public file public/.well-known/atproto-did, and the separate allowlist only covers /.well-known/workflow/. On self-hosted deployments, requests for this well-known resource therefore still redirect to /login instead of serving the file, leaving the public-asset fix incomplete.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/proxy.ts
Line: 59

Comment:
**Extensionless Asset Still Redirects**

The extension-only check does not match the existing public file `public/.well-known/atproto-did`, and the separate allowlist only covers `/.well-known/workflow/`. On self-hosted deployments, requests for this well-known resource therefore still redirect to `/login` instead of serving the file, leaving the public-asset fix incomplete.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This was against the first commit. The current version no longer keys on the extension: it stats the resolved path under public/, so /.well-known/atproto-did is served. Added it to the test cases.

@fancyHeat

Copy link
Copy Markdown
Author

hey @greptileai, please review the PR

@fancyHeat

Copy link
Copy Markdown
Author

hey @greptileai, please re-review the PR

Comment thread apps/web/proxy.ts Outdated
}
const file = resolve(publicDir, `.${decoded}`);
if (!file.startsWith(`${publicDir}${sep}`)) return false;
return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Malformed Paths Can Throw

A request path containing an encoded NUL byte, such as /%00, is decoded and passed to statSync. Node rejects that path with ERR_INVALID_ARG_VALUE, which throwIfNoEntry: false does not suppress. Because this call is outside the later error handler, the self-hosted proxy throws instead of redirecting the request to /login. Catch filesystem validation errors and treat them as a non-asset path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/proxy.ts
Line: 27

Comment:
**Malformed Paths Can Throw**

A request path containing an encoded NUL byte, such as `/%00`, is decoded and passed to `statSync`. Node rejects that path with `ERR_INVALID_ARG_VALUE`, which `throwIfNoEntry: false` does not suppress. Because this call is outside the later error handler, the self-hosted proxy throws instead of redirecting the request to `/login`. Catch filesystem validation errors and treat them as a non-asset path.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. The lookup is now inside the try, and there are tests for an encoded NUL, a file used as a directory, and an overlong name, all of which redirect to /login.

Comment thread apps/web/proxy.ts Outdated
}
const file = resolve(publicDir, `.${decoded}`);
if (!file.startsWith(`${publicDir}${sep}`)) return false;
return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Filesystem Errors Escape Proxy

A request pathname can make statSync fail for reasons other than a missing entry—for example, an overlong decoded path can produce ENAMETOOLONG. Since this call is outside a catch block, the error escapes proxy() and returns a 500 instead of following the existing /login redirect behavior. Treat filesystem lookup failures as “not a public asset.”

Suggested change
return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
try {
return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
} catch {
return false;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/proxy.ts
Line: 27

Comment:
**Filesystem Errors Escape Proxy**

A request pathname can make `statSync` fail for reasons other than a missing entry—for example, an overlong decoded path can produce `ENAMETOOLONG`. Since this call is outside a catch block, the error escapes `proxy()` and returns a 500 instead of following the existing `/login` redirect behavior. Treat filesystem lookup failures as “not a public asset.”

```suggestion
	try {
		return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false;
	} catch {
		return false;
	}
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. The lookup is now inside the try, and there are tests for an encoded NUL, a file used as a directory, and an overlong name, all of which redirect to /login.

statSync still throws for ENOTDIR, ENAMETOOLONG and an encoded NUL byte, and
that escaped proxy() as a 500 instead of the /login redirect.
@fancyHeat

Copy link
Copy Markdown
Author

hey @greptileai, please re-review the PR

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