Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface ServerConfigShape {
readonly devUrl: URL | undefined;
readonly noBrowser: boolean;
readonly authToken: string | undefined;
readonly allowedHosts: readonly string[];
readonly autoBootstrapProjectFromCwd: boolean;
readonly logWebSocketEvents: boolean;
}
Expand All @@ -50,6 +51,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
port: 0,
host: undefined,
authToken: undefined,
allowedHosts: [],
keybindingsConfigPath: path.join(statedir, "keybindings.json"),
staticDir: undefined,
devUrl: undefined,
Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ it.layer(testLayer)("server CLI command", (it) => {
"--no-browser",
"--auth-token",
"auth-secret",
"--allowed-hosts",
"app.example, ADMIN.example:8443",
]);

assert.equal(start.mock.calls.length, 1);
Expand All @@ -110,6 +112,7 @@ it.layer(testLayer)("server CLI command", (it) => {
assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
assert.equal(resolvedConfig?.noBrowser, true);
assert.equal(resolvedConfig?.authToken, "auth-secret");
assert.deepEqual(resolvedConfig?.allowedHosts, ["app.example", "admin.example:8443"]);
assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
assert.equal(resolvedConfig?.logWebSocketEvents, true);
assert.equal(stop.mock.calls.length, 1);
Expand All @@ -135,6 +138,7 @@ it.layer(testLayer)("server CLI command", (it) => {
VITE_DEV_SERVER_URL: "http://localhost:5173",
T3CODE_NO_BROWSER: "true",
T3CODE_AUTH_TOKEN: "env-token",
T3CODE_ALLOWED_HOSTS: "app.example, admin.example:8443",
});

assert.equal(start.mock.calls.length, 1);
Expand All @@ -145,6 +149,7 @@ it.layer(testLayer)("server CLI command", (it) => {
assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");
assert.equal(resolvedConfig?.noBrowser, true);
assert.equal(resolvedConfig?.authToken, "env-token");
assert.deepEqual(resolvedConfig?.allowedHosts, ["app.example", "admin.example:8443"]);
assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
assert.equal(resolvedConfig?.logWebSocketEvents, true);
assert.equal(findAvailablePort.mock.calls.length, 0);
Expand Down Expand Up @@ -233,6 +238,18 @@ it.layer(testLayer)("server CLI command", (it) => {
}),
);

it.effect("prefers --allowed-hosts over T3CODE_ALLOWED_HOSTS", () =>
Effect.gen(function* () {
yield* runCli(["--allowed-hosts", "cli.example:4321"], {
T3CODE_ALLOWED_HOSTS: "env.example",
T3CODE_NO_BROWSER: "true",
});

assert.equal(start.mock.calls.length, 1);
assert.deepEqual(resolvedConfig?.allowedHosts, ["cli.example:4321"]);
}),
);

it.effect("records a startup heartbeat with thread/project counts", () =>
Effect.gen(function* () {
const recordTelemetry = vi.fn(
Expand Down Expand Up @@ -288,6 +305,17 @@ it.layer(testLayer)("server CLI command", (it) => {
}),
);

it.effect("does not start server for invalid --allowed-hosts values", () =>
Effect.gen(function* () {
yield* runCli(["--allowed-hosts", "https://app.example/path"]).pipe(
Effect.catch(() => Effect.void),
);

assert.equal(start.mock.calls.length, 0);
assert.equal(stop.mock.calls.length, 0);
}),
);

it.effect("does not start server for out-of-range --port values", () =>
Effect.gen(function* () {
yield* runCli(["--port", "70000"]);
Expand Down
80 changes: 79 additions & 1 deletion apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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" },
),
Comment on lines +47 to +75
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

),
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

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


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>;
Expand All @@ -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>;
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -185,6 +254,7 @@ const ServerConfigLive = (input: CliInput) =>
devUrl,
noBrowser,
authToken,
allowedHosts,
autoBootstrapProjectFromCwd,
logWebSocketEvents,
} satisfies ServerConfigShape;
Expand Down Expand Up @@ -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.",
Expand All @@ -339,6 +416,7 @@ export const t3Cli = Command.make("t3", {
devUrl: devUrlFlag,
noBrowser: noBrowserFlag,
authToken: authTokenFlag,
allowedHosts: allowedHostsFlag,
autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
logWebSocketEvents: logWebSocketEventsFlag,
}).pipe(
Expand Down
Loading