Skip to content
Closed

sync #2701

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
29 changes: 28 additions & 1 deletion apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";

import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
import {
isAllowedBrowserApiCorsOrigin,
isLoopbackHostname,
resolveDevRedirectUrl,
} from "./http.ts";

describe("http dev routing", () => {
it("treats localhost and loopback addresses as local", () => {
Expand All @@ -25,3 +29,26 @@ describe("http dev routing", () => {
);
});
});

describe("browser API CORS origin allowlist", () => {
it("allows loopback and RFC6761 .localhost dev origins", () => {
expect(isAllowedBrowserApiCorsOrigin("http://localhost:5733")).toBe(true);
expect(isAllowedBrowserApiCorsOrigin("http://127.0.0.1:5733")).toBe(true);
expect(isAllowedBrowserApiCorsOrigin("http://vite.localhost:5733")).toBe(true);
});

it("allows Tailscale CGNAT and common private LAN ranges", () => {
expect(isAllowedBrowserApiCorsOrigin("http://100.91.197.39:5733")).toBe(true);
expect(isAllowedBrowserApiCorsOrigin("http://192.168.1.10:5733")).toBe(true);
expect(isAllowedBrowserApiCorsOrigin("http://10.0.0.5:5733")).toBe(true);
expect(isAllowedBrowserApiCorsOrigin("http://172.20.0.2:5733")).toBe(true);
});

it("rejects missing, invalid, or public origins", () => {
expect(isAllowedBrowserApiCorsOrigin(undefined)).toBe(false);
expect(isAllowedBrowserApiCorsOrigin("")).toBe(false);
expect(isAllowedBrowserApiCorsOrigin("not-a-url")).toBe(false);
expect(isAllowedBrowserApiCorsOrigin("https://example.com")).toBe(false);
expect(isAllowedBrowserApiCorsOrigin("http://185.199.108.153:5733")).toBe(false);
});
});
86 changes: 80 additions & 6 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
HttpBody,
HttpClient,
HttpClientResponse,
HttpMiddleware,
HttpRouter,
HttpServerResponse,
HttpServerRequest,
Expand All @@ -30,12 +31,6 @@ const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" vi
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);

export const browserApiCorsLayer = HttpRouter.cors({
allowedMethods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
maxAge: 600,
});

export function isLoopbackHostname(hostname: string): boolean {
const normalizedHostname = hostname
.trim()
Expand All @@ -44,6 +39,85 @@ export function isLoopbackHostname(hostname: string): boolean {
return LOOPBACK_HOSTNAMES.has(normalizedHostname);
}

/**
* Origins allowed to call browser credentialed API routes (cookies / `fetch(..., { credentials })`).
* Must echo a concrete `Access-Control-Allow-Origin` (never `*`) when credentials are enabled.
*/
export function isAllowedBrowserApiCorsOrigin(origin: string | undefined): boolean {
if (origin === undefined || origin.length === 0) {
return false;
}

let url: URL;
try {
url = new URL(origin);
} catch {
return false;
}

if (url.protocol !== "http:" && url.protocol !== "https:") {
return false;
}

const host = url.hostname;
if (isLoopbackHostname(host)) {
return true;
}

if (host.endsWith(".localhost")) {
return true;
}

if (host.includes(":")) {
const normalized = host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
if (normalized === "::1") {
return true;
}
// IPv6 unique local (fc00::/7)
if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
return true;
}
return false;
}

const octets = host.split(".").map((part) => Number(part));
if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
return false;
}

const a = octets[0];
const b = octets[1];
if (a === undefined || b === undefined) {
return false;
}
if (a === 10) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
// Tailscale CGNAT (100.64.0.0/10)
if (a === 100 && b >= 64 && b <= 127) {
return true;
}

return false;
}

export const browserApiCorsLayer = HttpRouter.middleware(
HttpMiddleware.cors({
allowedMethods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
maxAge: 600,
credentials: true,
allowedOrigins: isAllowedBrowserApiCorsOrigin,
}),
{ global: true },
);

export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
const redirectUrl = new URL(devUrl.toString());
redirectUrl.pathname = requestUrl.pathname;
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/environments/primary/target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { readPrimaryEnvironmentTarget } from "./target";

describe("readPrimaryEnvironmentTarget", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it("rewrites loopback configured URLs to the page hostname for LAN-style access", () => {
vi.stubEnv("VITE_HTTP_URL", "http://localhost:13773");
vi.stubEnv("VITE_WS_URL", "ws://localhost:13773");
vi.stubGlobal("window", {
location: new URL("http://100.64.0.2:5733/"),
desktopBridge: undefined,
});

const target = readPrimaryEnvironmentTarget();
expect(target).toEqual({
source: "configured",
target: {
httpBaseUrl: "http://100.64.0.2:13773/",
wsBaseUrl: "ws://100.64.0.2:13773/",
},
});
});

it("does not rewrite when the page is served from loopback", () => {
vi.stubEnv("VITE_HTTP_URL", "http://localhost:13773");
vi.stubEnv("VITE_WS_URL", "ws://localhost:13773");
vi.stubGlobal("window", {
location: new URL("http://127.0.0.1:5733/"),
desktopBridge: undefined,
});

const target = readPrimaryEnvironmentTarget();
expect(target).toEqual({
source: "configured",
target: {
httpBaseUrl: "http://localhost:13773/",
wsBaseUrl: "ws://localhost:13773/",
},
});
});
});
51 changes: 46 additions & 5 deletions apps/web/src/environments/primary/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null {
};
}

/**
* When the UI is opened from a non-loopback hostname (LAN / Tailscale) but the
* build still points the API at loopback, rewrite to the page hostname so the
* browser does not try to reach its own localhost.
*/
function rewriteConfiguredTargetLoopbackHostForPageHostname(
target: PrimaryEnvironmentTarget,
): PrimaryEnvironmentTarget {
if (target.source !== "configured") {
return target;
}

const pageHostname = normalizeHostname(new URL(window.location.href).hostname);
if (isLoopbackHostname(pageHostname)) {
return target;
}

const rewriteIfLoopback = (rawBaseUrl: string): string => {
const url = new URL(normalizeBaseUrl(rawBaseUrl));
if (!isLoopbackHostname(normalizeHostname(url.hostname))) {
return rawBaseUrl;
}
url.hostname = pageHostname;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IPv6 hostname rewrite silently fails due to missing brackets

Low Severity

When the page is accessed via an IPv6 address (e.g., http://[fd00::1]:5733/), pageHostname will be fd00::1 (the WHATWG URL getter strips brackets). Assigning this unbracketed IPv6 value via url.hostname = pageHostname is a silent no-op in the WHATWG URL API, because the colon is a forbidden domain code point and host parsing fails. The rewrite never actually takes effect for IPv6, leaving the URL pointing at localhost.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ea17fca. Configure here.

return url.toString();
};

return {
...target,
target: {
httpBaseUrl: rewriteIfLoopback(target.target.httpBaseUrl),
wsBaseUrl: rewriteIfLoopback(target.target.wsBaseUrl),
},
};
}

function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget {
const httpBaseUrl = normalizeBaseUrl(window.location.origin);
const url = new URL(httpBaseUrl);
Expand Down Expand Up @@ -150,9 +185,15 @@ export function resolvePrimaryEnvironmentHttpUrl(
}

export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null {
return (
resolveDesktopPrimaryTarget() ??
resolveConfiguredPrimaryTarget() ??
resolveWindowOriginPrimaryTarget()
);
const desktopTarget = resolveDesktopPrimaryTarget();
if (desktopTarget) {
return desktopTarget;
}

const configuredTarget = resolveConfiguredPrimaryTarget();
if (configuredTarget) {
return rewriteConfiguredTargetLoopbackHostForPageHostname(configuredTarget);
}

return resolveWindowOriginPrimaryTarget();
}
9 changes: 7 additions & 2 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import pkg from "./package.json" with { type: "json" };

const port = Number(process.env.PORT ?? 5733);
const host = process.env.HOST?.trim() || "localhost";
const wildcardBindHosts = new Set(["0.0.0.0", "::"]);
const viteListenHost = wildcardBindHosts.has(host) ? true : host;
const hmrUsesExplicitHost = !wildcardBindHosts.has(host);
const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim();
const configuredWsUrl = process.env.VITE_WS_URL?.trim();
const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
Expand Down Expand Up @@ -68,7 +71,7 @@ export default defineConfig({
tsconfigPaths: true,
},
server: {
host,
host: viteListenHost,
port,
strictPort: true,
...(devProxyTarget
Expand All @@ -94,7 +97,9 @@ export default defineConfig({
// inside Electron's BrowserWindow. Vite 8 uses console.debug for
// connection logs — enable "Verbose" in DevTools to see them.
protocol: "ws",
host,
// When listening on all interfaces, omit `host` so the client uses the
// page hostname (LAN / Tailscale) instead of ws://0.0.0.0 which breaks HMR.
...(hmrUsesExplicitHost ? { host } : {}),
},
},
build: {
Expand Down
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
"sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
},
"dependencies": {
"@t3tools/monorepo": "."
},
"devDependencies": {
"@types/node": "catalog:",
"oxfmt": "^0.40.0",
Expand Down
23 changes: 23 additions & 0 deletions scripts/dev-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0");
assert.equal(env.T3CODE_LOG_WS_EVENTS, "1");
assert.equal(env.T3CODE_HOST, "0.0.0.0");
assert.equal(env.HOST, "0.0.0.0");
assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/");
}),
);
Expand Down Expand Up @@ -141,6 +142,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
}),
);

it.effect("preserves T3CODE_NO_BROWSER from base env when noBrowser override is unset", () =>
Effect.gen(function* () {
const env = yield* createDevRunnerEnv({
mode: "dev",
baseEnv: {
T3CODE_NO_BROWSER: "true",
},
serverOffset: 0,
webOffset: 0,
t3Home: undefined,
noBrowser: undefined,
autoBootstrapProjectFromCwd: undefined,
logWebSocketEvents: undefined,
host: undefined,
port: undefined,
devUrl: undefined,
});

assert.equal(env.T3CODE_NO_BROWSER, "true");
}),
);

it.effect("uses custom t3Home when provided", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
Expand Down
11 changes: 8 additions & 3 deletions scripts/dev-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,15 @@ export function createDevRunnerEnv({

if (!isDesktopMode && host !== undefined) {
output.T3CODE_HOST = host;
// Vite reads `HOST` for dev-server bind address (see apps/web/vite.config.ts).
output.HOST = host;
}

// When `noBrowser` is set (CLI flag or dev-runner config), normalize for children.
// When it is unset, keep whatever came from `baseEnv` (e.g. `T3CODE_NO_BROWSER=true bun dev`).
// Do not delete here: with `extendEnv: false`, deleting would strip the user's env before turbo/t3.
if (!isDesktopMode && noBrowser !== undefined) {
output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0";
} else if (!isDesktopMode) {
delete output.T3CODE_NO_BROWSER;
}

if (autoBootstrapProjectFromCwd !== undefined) {
Expand Down Expand Up @@ -491,7 +494,9 @@ const devRunnerCli = Command.make("dev-runner", {
Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")),
),
host: Flag.string("host").pipe(
Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."),
Flag.withDescription(
"Bind host for the API server and Vite (forwards to T3CODE_HOST and HOST). Use 0.0.0.0 to listen on all interfaces (LAN, Tailscale, etc.).",
),
Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")),
),
port: Flag.integer("port").pipe(
Expand Down
2 changes: 2 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"globalEnv": [
"HOST",
"PORT",
"T3CODE_HOST",
"VITE_HTTP_URL",
"VITE_WS_URL",
"VITE_DEV_SERVER_URL",
"T3CODE_LOG_WS_EVENTS",
Expand Down
Loading