Skip to content

security: enforce auth policy for all endpoints in tunnel mode#1002

Open
Hybirdss wants to merge 2 commits intogarrytan:mainfrom
Hybirdss:security/tunnel-auth-policy
Open

security: enforce auth policy for all endpoints in tunnel mode#1002
Hybirdss wants to merge 2 commits intogarrytan:mainfrom
Hybirdss:security/tunnel-auth-policy

Conversation

@Hybirdss
Copy link
Copy Markdown
Contributor

@Hybirdss Hybirdss commented Apr 14, 2026

Summary

When BROWSE_TUNNEL=1 is set, the server is reachable from the public internet despite binding to 127.0.0.1. Several bootstrapping paths were designed for local-only access and had no auth checks — they silently became remote attack surfaces.

#904 fixed the cookie-picker token leak (one-time code exchange, no token in HTML). This PR adds the layer above it: a central gate that rejects unauthenticated requests to any endpoint when the tunnel is active — so future endpoints are protected by default, not by per-handler discipline.

Four issues closed by this PR:

1. Root token exposure via forged Origin header

curl -H "Origin: chrome-extension://anything" http://<ngrok-url>/health
# → { "token": "<ROOT_AUTH_TOKEN>", ... }

Any HTTP client can set the Origin header. The chrome-extension check was meaningful only on localhost.

2. Root token exposure in headed mode
Same /health endpoint — when the server starts in headed mode, the token was returned unconditionally regardless of tunnel state.

3. Cookie-picker accessible without auth in tunnel mode
Even after the #904 token-in-HTML fix, GET /cookie-picker was reachable without auth. Cookie picker reads local browser databases — it can't function remotely regardless, so the right response in tunnel mode is an explicit 403.

4. Inspector endpoints had no auth gate
POST /inspector/pick, GET /inspector/stream, and related paths accepted requests from anyone.


Root cause

server.ts route handlers each had their own ad-hoc auth checks designed for local-only access. When tunnel mode was added, no central enforcement was added — each handler's "localhost = trusted" assumption became a remote attack surface. Per-handler fixes (#904 and others) close individual gaps; this PR closes the class.


Fix

Single policy function runs before every route handler:

const TUNNEL_UNAUTHENTICATED_ALLOWLIST = new Set([
  // POST /connect — the remote pairing ceremony entry point.
  // Rate-limited, returns no root secrets, intentionally pre-auth.
  '/connect',
]);

function enforceTunnelPolicy(req: Request, url: URL): Response | null {
  if (!tunnelActive) return null; // local mode — existing behaviour unchanged
  if (TUNNEL_UNAUTHENTICATED_ALLOWLIST.has(url.pathname)) return null;
  if (req.method === 'OPTIONS') return null;
  if (!validateAuth(req) && !getTokenInfo(req)) {
    return new Response(/* 401 + pairing hint */);
  }
  return null;
}

Adding a new allowlist entry requires deliberate justification — the comment forces the question "am I intentionally exposing this to the internet without auth?"

Per-endpoint defense-in-depth:

  • /health: token delivery gated behind !tunnelActive; returns extensionUnavailable: true with a hint to use --pair instead
  • /cookie-picker: explicit 403 in tunnel mode (reads local browser databases — can't work remotely regardless)
  • /inspector: auth gate at the top of the block covers all sub-paths

/token is intentionally not on the allowlist — it has its own isRootRequest() guard and works correctly in tunnel mode for callers who already hold the root token.

Local mode behavior is unchanged. enforceTunnelPolicy returns immediately when tunnelActive is false.


Test plan

Tests 13a–13h added to browse/test/server-auth.test.ts — same static-analysis pattern as existing tests. Each test documents which attack scenario it guards against and verifies the fix survives future refactors.

✓ TUNNEL_UNAUTHENTICATED_ALLOWLIST contains /connect and nothing else sensitive
✓ enforceTunnelPolicy fires before the first route handler
✓ enforceTunnelPolicy responds 401 with pairing hint
✓ /health delivers AUTH_TOKEN only when the tunnel is NOT active
✓ /health returns extensionUnavailable hint in tunnel mode
✓ /cookie-picker returns 403 in tunnel mode
✓ /inspector auth gate appears before /inspector/pick handler
✓ /inspector auth gate accepts both root and scoped tokens

All 126 tests pass (on latest main, after rebase over #988).

@Hybirdss Hybirdss force-pushed the security/tunnel-auth-policy branch 2 times, most recently from 1367a80 to 9abcc60 Compare April 14, 2026 17:52
When BROWSE_TUNNEL=1 is set the server is reachable from the public
internet despite binding to 127.0.0.1.  Three bootstrapping paths
that relied on the localhost trust assumption silently became remote
attack surfaces:

- GET /health returned the root AUTH_TOKEN to any caller that forged
  an `Origin: chrome-extension://...` header (trivially spoofable).
- GET /health also returned the token unconditionally in headed mode,
  regardless of tunnel state.
- GET /cookie-picker served the root token embedded in HTML with no
  auth gate, readable by anyone with the ngrok URL.
- POST /inspector/pick and the inspector SSE endpoint had no auth
  check at all (localhost-only assumption).

Fix: single `enforceTunnelPolicy()` function runs before every route
handler.  When the tunnel is active it rejects unauthenticated
requests unless the path is on an explicit allowlist.  The allowlist
currently contains only `/connect` (the remote pairing ceremony entry
point, which is intentionally pre-auth and rate-limited).  Adding any
new entry requires deliberate justification — "I am exposing this
endpoint to the internet without auth."

Per-endpoint hardening provides defense-in-depth:
- /health: token delivery gated behind `!tunnelActive`; returns an
  `extensionUnavailable` hint instead so remote callers know to use
  the /connect → /token pairing flow.
- /cookie-picker: returns 403 in tunnel mode (the endpoint reads
  local browser databases and cannot function remotely regardless).
- /inspector: explicit auth gate covers all sub-paths before any
  handler runs.

/token is intentionally NOT on the allowlist: it has its own
`isRootRequest()` guard and is reachable in tunnel mode for callers
that already hold the root token.

Tests 13a–13h in server-auth.test.ts verify each attack scenario is
blocked and guard against future regressions.

refine: add /token exclusion note to tunnel allowlist comment

ci: trigger re-run with fork secrets configured
@Hybirdss Hybirdss force-pushed the security/tunnel-auth-policy branch from e682332 to 7df20ea Compare April 14, 2026 18:09
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