Skip to content

fix(server): same-origin check on cookie WebSocket upgrades, Secure session cookie - #65

Merged
DanielGGordon merged 1 commit into
mainfrom
t3code/ws-origin-secure-cookie
Oct 7, 2026
Merged

DanielGGordon merged 1 commit into
mainfrom
t3code/ws-origin-secure-cookie

Conversation

@DanielGGordon

Copy link
Copy Markdown
Owner

Every app on this box shares one IP, and cookies are scoped by host, not port. That causes two problems:

  • Cross-origin /ws hijack. A page on any other port (DanCode :8443, Abba Bank :9443, Alfred :6443, a test deploy, or later a sibling subdomain under gordonlabs.dev) can open wss://…:7443/ws, and the browser attaches the T3 session cookie. One XSS in any co-hosted app could take over T3, including its terminals.
  • Cleartext cookie. The session cookie has no Secure flag, so the browser also sends it in plain text to the raw HTTP ports on the same IP (:3000, :3001, :4173, :8000, :8080).

These are items H1 and H3 from the box security review. The plan is being tracked in the consolidated deployment-security thread.

Fix

  • Same-origin check on cookie upgrades. authenticateWebSocketUpgrade now rejects a cookie-authenticated upgrade (one with no wsTicket and no Authorization header) whose Origin is not the page's own origin. The expected origin comes from X-Forwarded-Proto plus X-Forwarded-Host, falling back to Host; Caddy's header_up Host {host} drops the port, but the port is what separates the apps.
  • Allowlist unchanged. The existing credentialed CORS allowlist (dev origin, desktop renderer, T3CODE_DEV_ALLOWED_ORIGINS) still passes. Requests without an Origin header (non-browser clients) are unaffected. The ticket, bearer and DPoP paths are unchanged, so mobile, desktop and app.t3.codes keep working.
  • Secure cookie over HTTPS. The session cookie is marked Secure whenever the request arrived over HTTPS. /api/auth/session re-sets it over HTTPS, so existing browser sessions pick up the flag without re-pairing.
  • DESKTOP_RENDERER_ORIGINS moved into the new auth/browserOrigin.ts, so CORS and the upgrade check share one list.

Verification

  • vp test run src/auth/browserOrigin.test.ts src/auth/EnvironmentAuth.test.ts src/auth/http.test.ts: 24 tests pass. The new cases cover same port vs. another port, scheme mismatch, the default 443 port, sibling subdomains, the allowlist, a missing Origin, and a ticket from another origin.
  • tsc --noEmit on apps/server reports no new errors (the existing externalLauncher.test.ts errors are unrelated).
  • Test deploy (see comment): browser login and the WebSocket work through Caddy, and a cross-port Origin is rejected with 401.

🤖 Generated with Claude Code

…ession cookie

Browsers attach the T3 session cookie to a WebSocket opened by any same-site
page. On this box every app shares one IP (and later, sibling subdomains of one
domain), so a page on another port could open /ws with the user's session.
Cookie-authenticated upgrades now require the page's own origin (or the
existing credentialed CORS allowlist); ticket, bearer and DPoP clients are
unchanged.

The session cookie is also marked Secure when the request arrived over HTTPS,
and re-set on the session check so existing cookies pick it up without a
re-pair. Cookies ignore ports, so without it the prod cookie was sent in
cleartext to the plain-HTTP apps on the same IP.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M labels Oct 7, 2026
@DanielGGordon

Copy link
Copy Markdown
Owner Author

Test deployment (degraded / tunnel mode)

This branch is running on a local test instance, but the external HTTPS
port is not reachable yet (Caddy not bootstrapped or OVH port closed), so
there is no public URL. Reach it over an SSH tunnel:

# Open the tunnel from your laptop (keep this session open):
ssh -L 8080:127.0.0.1:3779 dgordon@<SERVER_IP>
# THEN, inside that SSH session (i.e. on <SERVER_IP>, not your laptop),
# mint a link whose URL points at your tunnel origin:
node scripts/test-status.ts --pair 7449 --base-url http://127.0.0.1:8080
# finally open the printed http://127.0.0.1:8080/pair#token=... in your local browser

Slot: external 7449 ⇄ loopback 3779.

@DanielGGordon

Copy link
Copy Markdown
Owner Author

Test deployment (correction)

The auto-posted comment above says "degraded mode". That came from a startup race: the external port answers fine.

Test instance: https://15.204.108.12:7449

To get a pairing link (deliberately not posted here), run on the box:
node scripts/test-status.ts --pair 7449 --base-url https://15.204.108.12:7449
Without --base-url, that command currently fails with TypeError: Invalid URL.

Checked through Caddy on this slot:

check result
POST /api/auth/browser-session 200
session cookie flags HttpOnly; Secure; SameSite=Lax
/ws with Origin: https://15.204.108.12:7449 (same origin) 101
/ws with Origin: https://15.204.108.12:8443 (another port) 401
/ws with Origin: http://15.204.108.12:7449 (wrong scheme) 401
/ws with no Origin header 101

@DanielGGordon
DanielGGordon merged commit 024d7bd into main Oct 7, 2026
7 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant