Skip to content

Enforce allowed host and WebSocket origin checks on server ingress#1169

Open
juliusmarminge wants to merge 2 commits intomainfrom
t3code/verify-websocket-origin-header
Open

Enforce allowed host and WebSocket origin checks on server ingress#1169
juliusmarminge wants to merge 2 commits intomainfrom
t3code/verify-websocket-origin-header

Conversation

@juliusmarminge
Copy link
Member

@juliusmarminge juliusmarminge commented Mar 17, 2026

Summary

  • add allowedHosts to server config and wire it through CLI/env (--allowed-hosts, T3CODE_ALLOWED_HOSTS)
  • validate and normalize allowed host entries as bare host[:port] values (deduped, lowercased)
  • enforce allowed-host checks for both HTTP requests and WebSocket upgrades
  • enforce WebSocket origin verification in production (including reverse-proxy forwarded host/proto handling)
  • improve upgrade rejection status text handling for 403 Forbidden
  • add/extend tests for CLI parsing/precedence, invalid host input, HTTP host filtering, and WebSocket origin/host scenarios

Testing

  • apps/server/src/main.test.ts: verifies CLI + env parsing for allowed hosts, CLI precedence over env, and invalid --allowed-hosts rejection path
  • apps/server/src/wsServer.test.ts: verifies HTTP 403 Forbidden host for disallowed hosts, HTTP allow path for allowed hosts, and WebSocket allow/deny cases for missing origin, mismatched origin, forwarded proxy headers, allowed hosts, and dev-url bypass
  • Not run: bun fmt
  • Not run: bun lint
  • Not run: bun typecheck
  • Not run: bun run test

Note

Medium Risk
Introduces new ingress validation that can reject previously accepted HTTP/WebSocket traffic (host allowlist and required Origin matching), which may break deployments/clients if headers or config are mis-set.

Overview
Adds an ingress allowlist and stricter WebSocket verification. The server config now includes allowedHosts, configurable via --allowed-hosts and T3CODE_ALLOWED_HOSTS, with validation/normalization (bare host[:port], lowercased, deduped) and CLI taking precedence over env.

HTTP requests and WebSocket upgrades are now blocked with 403 when Host/X-Forwarded-Host is not in the allowlist (empty list still allows all). In non-dev mode, WebSocket upgrades additionally require a matching Origin (including X-Forwarded-Proto handling), with improved upgrade rejection status text, and expanded tests covering parsing, precedence, invalid inputs, and allow/deny scenarios.

Written by Cursor Bugbot for commit 11091bf. This will update automatically on new commits. Configure here.

Note

Enforce allowed host and WebSocket origin checks on server ingress

  • Adds an --allowed-hosts CLI flag and T3CODE_ALLOWED_HOSTS env var accepting comma-separated host[:port] values; invalid entries (with scheme, path, etc.) cause a parse failure.
  • HTTP requests are rejected with 403 if the Host or X-Forwarded-Host header does not match the configured allowlist (when non-empty).
  • WebSocket upgrades are rejected with 403 for disallowed hosts and for missing or mismatched Origin headers; origin checks are skipped when devUrl is configured.
  • Reverse-proxy forwarded headers (X-Forwarded-Host, X-Forwarded-Proto) are honored during host and origin validation.
  • Risk: existing deployments behind a reverse proxy with a non-empty allowedHosts list must ensure forwarded headers are set correctly, or all requests will be rejected.
📊 Macroscope summarized 49dda48. 4 files reviewed, 1 issue evaluated, 0 issues filtered, 1 comment posted

🗂️ Filtered Issues

- Add `--allowed-hosts` / `T3CODE_ALLOWED_HOSTS` config parsing and validation
- Reject HTTP and WebSocket requests from disallowed hosts with 403
- Enforce production WebSocket origin matching (including proxy headers), with dev-url bypass
- Expand CLI and server tests for host filtering and origin verification
@coderabbitai
Copy link

coderabbitai bot commented Mar 17, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b1b63abd-c485-4ea3-82c7-617254346609

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/verify-websocket-origin-header
📝 Coding Plan
  • Generate coding plan for human review comments

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

@github-actions github-actions bot added size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 17, 2026
- validate allowed hosts with `Schema.Trim` + `Schema.check`
- normalize hosts via lowercase decode step
- simplify CSV decode/encode by removing Effect-wrapped transforms
Comment on lines +47 to +75
const invalidAllowedHostIssue = (input: string) =>
new SchemaIssue.InvalidValue(Option.some(input), {
message: `Invalid host "${input}". Expected bare host[:port], for example "app.example" or "app.example:443".`,
});

const AllowedHost = Schema.Trim.pipe(
Schema.check(
Schema.makeFilter(
(input) => {
if (input.length === 0 || input.includes("://")) {
return invalidAllowedHostIssue(input);
}

const candidateUrl = `http://${input}`;
if (!URL.canParse(candidateUrl)) {
return invalidAllowedHostIssue(input);
}

const parsed = new URL(candidateUrl);
return parsed.username ||
parsed.password ||
parsed.pathname !== "/" ||
parsed.search ||
parsed.hash
? invalidAllowedHostIssue(input)
: true;
},
{ identifier: "AllowedHost" },
),
Copy link
Contributor

Choose a reason for hiding this comment

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

🟢 Low src/main.ts:47

The AllowedHost validation accepts port-only inputs like :8080. When parsed as new URL("http://:8080"), parsed.hostname is empty ("") but parsed.host is ":8080". The code returns parsed.host.toLowerCase() without checking that parsed.hostname is non-empty, so :8080 passes validation as a valid host.

        const parsed = new URL(candidateUrl);
         if (
           parsed.username ||
           parsed.password ||
+          parsed.hostname === "" ||
           parsed.pathname !== "/" ||
           parsed.search ||
           parsed.hash
         ) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/main.ts around lines 47-75:

The `AllowedHost` validation accepts port-only inputs like `:8080`. When parsed as `new URL("http://:8080")`, `parsed.hostname` is empty (`""`) but `parsed.host` is `":8080"`. The code returns `parsed.host.toLowerCase()` without checking that `parsed.hostname` is non-empty, so `:8080` passes validation as a valid host.

Evidence trail:
- apps/server/src/main.ts lines 47-76 (at REVIEWED_COMMIT): Shows AllowedHost validation logic that checks username, password, pathname, search, hash but NOT hostname
- Node.js URL documentation (https://nodejs.org/api/url.html): Confirms that `new URL("http://:8080")` has `hostname = ""` and `host = ":8080"`, and `URL.canParse("http://:8080")` returns true

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Trailing slash passes AllowedHost validation but never matches
    • Changed the AllowedHost schema transformation to store new URL("http://" + input).host instead of the raw lowercased input, which strips trailing slashes and ensures consistency with normalizeRequestHost.
  • ✅ Resolved by another fix: Default port stripped at lookup but not at storage
    • The same fix that stores parsed.host instead of the raw input also strips default ports (e.g. :80), since the WHATWG URL parser's .host property omits default ports for the scheme.

Create PR

Or push these changes by commenting:

@cursor push ec7629b2b1
Preview (ec7629b2b1)
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -74,7 +74,13 @@
       { identifier: "AllowedHost" },
     ),
   ),
-  Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()),
+  Schema.decodeTo(
+    Schema.String,
+    SchemaTransformation.transform({
+      decode: (input) => new URL(`http://${input}`).host,
+      encode: (input) => input,
+    }),
+  ),
 );
 
 const AllowedHostsCsv = Schema.String.pipe(

),
),
Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()),
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Trailing slash passes AllowedHost validation but never matches

Low Severity

The AllowedHost schema stores the lowercased original input string, so an entry like "app.example/" passes validation (because parsed.pathname === "/" satisfies the !== "/" guard) but is stored as "app.example/" with a trailing slash. Meanwhile, normalizeRequestHost returns parsed.host which is "app.example" without a slash. The allowedHostSet.has(requestHost) check therefore never matches, silently creating an allowlist entry that blocks all requests rather than permitting the intended host.

Additional Locations (1)
Fix in Cursor Fix in Web

),
),
Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()),
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Default port stripped at lookup but not at storage

Low Severity

AllowedHost stores the lowercased original input string via SchemaTransformation.toLowerCase(), but normalizeRequestHost extracts parsed.host from a http:// URL, which strips the default HTTP port (80) per the WHATWG URL spec. This means an entry like "app.example:80" passes validation and is stored as "app.example:80", but incoming requests with Host: app.example:80 produce "app.example" (no port) from normalizeRequestHost, so allowedHostSet.has(requestHost) always returns false — silently blocking the host rather than allowing it.

Additional Locations (1)
Fix in Cursor Fix in Web

@Sma-Das
Copy link

Sma-Das commented Mar 17, 2026

Adding, at apps/server/src/main.ts:L241-L244, using this on web mode results in the host being 0.0.0.0 by default; which negates the intention behind allowedHosts. It should default to the loopback ("127.0.0.1", "::1", "[::1]", "localhost") and induces users to then utilize allowdHosts if they want to access T3 Code from another endpoint.

edit: or a --allow-remote flag that makes it easier to configure remote access rather than users figuring out network subnets.

@juliusmarminge
Copy link
Member Author

the remote mode will be more of a first class citizen soon and we'll make sure things are super solid when we get there for sure!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). 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.

2 participants