-
Notifications
You must be signed in to change notification settings - Fork 891
Enforce allowed host and WebSocket origin checks on server ingress #1169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,19 @@ | |
| * | ||
| * @module CliConfig | ||
| */ | ||
| import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; | ||
| import { | ||
| Config, | ||
| Data, | ||
| Effect, | ||
| FileSystem, | ||
| Layer, | ||
| Option, | ||
| Path, | ||
| Schema, | ||
| SchemaIssue, | ||
| SchemaTransformation, | ||
| ServiceMap, | ||
| } from "effect"; | ||
| import { Command, Flag } from "effect/unstable/cli"; | ||
| import { NetService } from "@t3tools/shared/Net"; | ||
| import { | ||
|
|
@@ -32,6 +44,57 @@ export class StartupError extends Data.TaggedError("StartupError")<{ | |
| readonly cause?: unknown; | ||
| }> {} | ||
|
|
||
| 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" }, | ||
| ), | ||
| ), | ||
| Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trailing slash passes AllowedHost validation but never matchesLow Severity The Additional Locations (1)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Default port stripped at lookup but not at storageLow Severity
Additional Locations (1) |
||
|
|
||
| const AllowedHostsCsv = Schema.String.pipe( | ||
| Schema.decodeTo( | ||
| Schema.Array(AllowedHost), | ||
| SchemaTransformation.transform({ | ||
| decode: (input): readonly string[] => | ||
| Array.from( | ||
| new Set( | ||
| input | ||
| .split(",") | ||
| .map((entry) => entry.trim()) | ||
| .filter((entry) => entry.length > 0), | ||
| ), | ||
| ), | ||
| encode: (input: readonly string[]) => input.join(", "), | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| interface CliInput { | ||
| readonly mode: Option.Option<RuntimeMode>; | ||
| readonly port: Option.Option<number>; | ||
|
|
@@ -40,6 +103,7 @@ interface CliInput { | |
| readonly devUrl: Option.Option<URL>; | ||
| readonly noBrowser: Option.Option<boolean>; | ||
| readonly authToken: Option.Option<string>; | ||
| readonly allowedHosts: Option.Option<readonly string[]>; | ||
| readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | ||
| readonly logWebSocketEvents: Option.Option<boolean>; | ||
| } | ||
|
|
@@ -112,6 +176,10 @@ const CliEnvConfig = Config.all({ | |
| Config.option, | ||
| Config.map(Option.getOrUndefined), | ||
| ), | ||
| allowedHosts: Config.schema(AllowedHostsCsv, "T3CODE_ALLOWED_HOSTS").pipe( | ||
| Config.option, | ||
| Config.map(Option.getOrUndefined), | ||
| ), | ||
| autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( | ||
| Config.option, | ||
| Config.map(Option.getOrUndefined), | ||
|
|
@@ -158,6 +226,7 @@ const ServerConfigLive = (input: CliInput) => | |
| const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); | ||
| const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); | ||
| const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; | ||
| const allowedHosts = Option.getOrUndefined(input.allowedHosts) ?? env.allowedHosts ?? []; | ||
| const autoBootstrapProjectFromCwd = resolveBooleanFlag( | ||
| input.autoBootstrapProjectFromCwd, | ||
| env.autoBootstrapProjectFromCwd ?? mode === "web", | ||
|
|
@@ -185,6 +254,7 @@ const ServerConfigLive = (input: CliInput) => | |
| devUrl, | ||
| noBrowser, | ||
| authToken, | ||
| allowedHosts, | ||
| autoBootstrapProjectFromCwd, | ||
| logWebSocketEvents, | ||
| } satisfies ServerConfigShape; | ||
|
|
@@ -317,6 +387,13 @@ const authTokenFlag = Flag.string("auth-token").pipe( | |
| Flag.withAlias("token"), | ||
| Flag.optional, | ||
| ); | ||
| const allowedHostsFlag = Flag.string("allowed-hosts").pipe( | ||
| Flag.withSchema(AllowedHostsCsv), | ||
| Flag.withDescription( | ||
| "Comma-separated host[:port] values allowed for inbound HTTP and WebSocket traffic (equivalent to T3CODE_ALLOWED_HOSTS).", | ||
| ), | ||
| Flag.optional, | ||
| ); | ||
| const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( | ||
| Flag.withDescription( | ||
| "Create a project for the current working directory on startup when missing.", | ||
|
|
@@ -339,6 +416,7 @@ export const t3Cli = Command.make("t3", { | |
| devUrl: devUrlFlag, | ||
| noBrowser: noBrowserFlag, | ||
| authToken: authTokenFlag, | ||
| allowedHosts: allowedHostsFlag, | ||
| autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | ||
| logWebSocketEvents: logWebSocketEventsFlag, | ||
| }).pipe( | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟢 Low
src/main.ts:47The
AllowedHostvalidation accepts port-only inputs like:8080. When parsed asnew URL("http://:8080"),parsed.hostnameis empty ("") butparsed.hostis":8080". The code returnsparsed.host.toLowerCase()without checking thatparsed.hostnameis non-empty, so:8080passes 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: