Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ If a tradeoff is required, choose correctness and robustness over short-term con

Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem.

## Verifying Changes in the Browser

Starting a dev server to verify changes is expected and allowed:

- `T3CODE_DEV_INSTANCE=<worktree-name> bun run dev` runs an isolated instance
with deterministic port offsets (server base `13773`, web base `5733`; the
runner probes for free ports and prints the chosen pair).
- On localhost dev servers the web UI authenticates automatically — no pairing
code needed. Just load the printed web port (e.g. `http://localhost:5733`).
This applies to fresh/headless browser profiles too.
- Dev instances sharing the default `T3CODE_HOME` (`~/.t3`) share state under
`~/.t3/dev`; a browser session carries across instances on the same hostname.
Use `localhost` consistently — it does not share cookies with `127.0.0.1`.

See `docs/reference/scripts.md` for parallel-instance details and
`docs/cloud/environment-auth.md` for how dev auto-auth is secured.

## Package Roles

- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sql

export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
export const INTERNAL_DEV_AUTO_BOOTSTRAP_SUBJECT = "dev-auto-bootstrap";

export interface IssuedPairingLink {
readonly id: string;
Expand Down Expand Up @@ -446,6 +447,10 @@ export class EnvironmentAuth extends Context.Service<
AuthPairingCredentialResult,
ServerAuthInternalError
>;
readonly issueDevAutoPairingCredential: () => Effect.Effect<
AuthPairingCredentialResult,
ServerAuthInternalError
>;
readonly listPairingLinks: (input?: {
readonly excludeSubjects?: ReadonlyArray<string>;
}) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>;
Expand Down Expand Up @@ -793,6 +798,7 @@ export const make = Effect.gen(function* () {
Effect.map((pairingLinks) => {
const excludedSubjects = input?.excludeSubjects ?? [
INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
INTERNAL_DEV_AUTO_BOOTSTRAP_SUBJECT,
];
return pairingLinks
.filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject))
Expand Down Expand Up @@ -874,6 +880,17 @@ export const make = Effect.gen(function* () {
subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
}).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));

// Same administrative grant as startup pairing, under its own subject so
// dev-auto sessions are identifiable and excluded from pairing-link
// listings. Only reachable through the guarded dev-only HTTP endpoint.
const issueDevAutoPairingCredential: EnvironmentAuth["Service"]["issueDevAutoPairingCredential"] =
() =>
issuePairingCredentialForSubject({
scopes: AuthAdministrativeScopes,
subject: INTERNAL_DEV_AUTO_BOOTSTRAP_SUBJECT,
label: "dev-auto",
}).pipe(Effect.withSpan("EnvironmentAuth.issueDevAutoPairingCredential"));

const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
listSessions().pipe(
Effect.map((clientSessions) =>
Expand Down Expand Up @@ -964,6 +981,7 @@ export const make = Effect.gen(function* () {
createPairingLink,
issuePairingCredential,
issueStartupPairingCredential,
issueDevAutoPairingCredential,
listPairingLinks,
revokePairingLink,
issueSession,
Expand Down
139 changes: 139 additions & 0 deletions apps/server/src/auth/devPairing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { ServerAuthDescriptor } from "@t3tools/contracts";
import { assert, describe, it } from "@effect/vitest";

import { isDevPairingEligible, validateDevPairingRequestHeaders } from "./devPairing.ts";

const descriptorWithPolicy = (policy: ServerAuthDescriptor["policy"]): ServerAuthDescriptor => ({
policy,
bootstrapMethods: ["one-time-token"],
sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"],
sessionCookieName: "t3_session",
});

const devUrl = new URL("http://localhost:5733");

describe("isDevPairingEligible", () => {
it("is eligible only for loopback web-dev servers", () => {
assert.isTrue(
isDevPairingEligible({ mode: "web", devUrl }, descriptorWithPolicy("loopback-browser")),
);
});

it("rejects production configurations (no devUrl)", () => {
assert.isFalse(
isDevPairingEligible(
{ mode: "web", devUrl: undefined },
descriptorWithPolicy("loopback-browser"),
),
);
});

it("rejects desktop mode even with a loopback devUrl", () => {
assert.isFalse(
isDevPairingEligible(
{ mode: "desktop", devUrl },
descriptorWithPolicy("desktop-managed-local"),
),
);
assert.isFalse(
isDevPairingEligible({ mode: "desktop", devUrl }, descriptorWithPolicy("loopback-browser")),
);
});

it("rejects remote-reachable policies", () => {
assert.isFalse(
isDevPairingEligible({ mode: "web", devUrl }, descriptorWithPolicy("remote-reachable")),
);
});

it("rejects non-loopback dev URLs so --dev-url cannot become an auth root", () => {
assert.isFalse(
isDevPairingEligible(
{ mode: "web", devUrl: new URL("https://some-site.example") },
descriptorWithPolicy("loopback-browser"),
),
);
assert.isFalse(
isDevPairingEligible(
{ mode: "web", devUrl: new URL("http://192.168.1.20:5733") },
descriptorWithPolicy("loopback-browser"),
),
);
});

it("accepts loopback dev URL variants", () => {
for (const url of ["http://127.0.0.1:5733", "http://[::1]:5733"]) {
assert.isTrue(
isDevPairingEligible(
{ mode: "web", devUrl: new URL(url) },
descriptorWithPolicy("loopback-browser"),
),
url,
);
}
});
});

describe("validateDevPairingRequestHeaders", () => {
it("accepts an exact dev-origin Origin with a loopback Host", () => {
assert.equal(
validateDevPairingRequestHeaders({
originHeader: "http://localhost:5733",
hostHeader: "localhost:13773",
devUrl,
}),
null,
);
});

it("rejects missing, null, or foreign Origins", () => {
for (const originHeader of [undefined, "", "null", "https://evil.example"]) {
assert.equal(
validateDevPairingRequestHeaders({
originHeader,
hostHeader: "localhost:13773",
devUrl,
}),
"origin_mismatch",
String(originHeader),
);
}
});

it("rejects Origins that differ from the dev origin only by port or scheme", () => {
for (const originHeader of [
"http://localhost:5734",
"https://localhost:5733",
"http://127.0.0.1:5733",
]) {
assert.equal(
validateDevPairingRequestHeaders({
originHeader,
hostHeader: "localhost:13773",
devUrl,
}),
"origin_mismatch",
originHeader,
);
}
});

it("rejects non-loopback Hosts (DNS rebinding) even with a matching Origin", () => {
assert.equal(
validateDevPairingRequestHeaders({
originHeader: "http://localhost:5733",
hostHeader: "evil.example:13773",
devUrl,
}),
"host_not_loopback",
);
assert.equal(
validateDevPairingRequestHeaders({
originHeader: "http://localhost:5733",
hostHeader: undefined,
devUrl,
}),
"host_not_loopback",
);
});
});
47 changes: 47 additions & 0 deletions apps/server/src/auth/devPairing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* devPairing - Eligibility and request validation for dev-mode silent pairing.
*
* The dev pairing endpoint mints an administrative one-time credential with no
* prior authentication, so eligibility is deliberately narrow: a web-mode dev
* server, bound to loopback (policy "loopback-browser"), whose configured dev
* URL is itself loopback. Request-level checks then require a browser-attached
* Origin matching the dev origin exactly — the Origin header survives the Vite
* dev proxy unmodified, which Host-based checks do not — plus a loopback Host
* as anti-DNS-rebinding defense for direct requests.
*
* @module devPairing
*/
import type { ServerAuthDescriptor } from "@t3tools/contracts";

import type { ServerConfig } from "../config.ts";
import { isLoopbackHost, isLoopbackHostHeader, normalizeHost } from "../netHost.ts";

export interface DevPairingConfigInput {
readonly mode: ServerConfig["Service"]["mode"];
readonly devUrl: URL | undefined;
}

export const isDevPairingEligible = (
config: DevPairingConfigInput,
descriptor: ServerAuthDescriptor,
): boolean =>
config.mode === "web" &&
config.devUrl !== undefined &&
isLoopbackHost(normalizeHost(config.devUrl.hostname)) &&
descriptor.policy === "loopback-browser";

export type DevPairingRequestRejection = "origin_mismatch" | "host_not_loopback";

export const validateDevPairingRequestHeaders = (input: {
readonly originHeader: string | undefined;
readonly hostHeader: string | undefined;
readonly devUrl: URL;
}): DevPairingRequestRejection | null => {
if (!input.originHeader || input.originHeader.trim() !== input.devUrl.origin) {
return "origin_mismatch";
}
if (!isLoopbackHostHeader(input.hostHeader)) {
return "host_not_loopback";
}
return null;
};

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.

Proxied Origin forge bypass

Medium Severity

The Host loopback check is defeated for requests through the Vite /api proxy because changeOrigin rewrites Host to the loopback upstream. Eligibility only constrains the server bind address, not the Vite listen address. If Vite is LAN-exposed (HOST=0.0.0.0), a remote client can forge Origin to the configured devUrl origin and mint an administrative pairing credential without local filesystem access.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 929d35a. Configure here.

45 changes: 44 additions & 1 deletion apps/server/src/auth/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import * as SessionStore from "./SessionStore.ts";
import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts";
import { deriveAuthClientMetadata } from "./utils.ts";
import { verifyRequestDpopProof } from "./dpop.ts";
import { isDevPairingEligible, validateDevPairingRequestHeaders } from "./devPairing.ts";
import * as ServerConfig from "../config.ts";

const CREDENTIAL_RESPONSE_HEADERS = {
"cache-control": "no-store",
Expand Down Expand Up @@ -125,7 +127,9 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope
);
}

function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") {
function failEnvironmentOperationForbidden(
reason: "current_session_revoke_not_allowed" | "dev_pairing_request_rejected",
) {
return currentEnvironmentTraceId.pipe(
Effect.flatMap((traceId) =>
Effect.fail(
Expand Down Expand Up @@ -331,6 +335,45 @@ export const authHttpApiLayer = HttpApiBuilder.group(
),
),
)
.handle(
"devPairingCredential",
Effect.fn("environment.auth.devPairingCredential")(
function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
const config = yield* ServerConfig.ServerConfig;
const descriptor = yield* serverAuth.getDescriptor();

// Ineligible configurations answer not_found so the endpoint is
// indistinguishable from absent outside local web dev. The
// handler must exist unconditionally: unregistered paths fall
// into the SPA/redirect wildcard route, not a 404.
if (!isDevPairingEligible(config, descriptor) || config.devUrl === undefined) {
return yield* failEnvironmentNotFound("dev_pairing_not_available");
}

const request = yield* HttpServerRequest.HttpServerRequest;
const rejection = validateDevPairingRequestHeaders({
originHeader: request.headers["origin"],
hostHeader: request.headers["host"],
devUrl: config.devUrl,
});
if (rejection !== null) {
yield* Effect.logWarning("rejected dev pairing request", {
rejection,
origin: request.headers["origin"] ?? null,
host: request.headers["host"] ?? null,
});
return yield* failEnvironmentOperationForbidden("dev_pairing_request_rejected");
}

yield* appendCredentialResponseHeaders;
return yield* serverAuth.issueDevAutoPairingCredential();
},
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("dev_pairing_credential_issuance_failed", error),
),
),
)
.handle(
"pairingCredential",
Effect.fn("environment.auth.pairingCredential")(
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/netHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { assert, describe, it } from "@effect/vitest";

import { isLoopbackHost, isLoopbackHostHeader, parseHostHeaderHostname } from "./netHost.ts";

describe("parseHostHeaderHostname", () => {
it("extracts hostnames from host headers with and without ports", () => {
assert.equal(parseHostHeaderHostname("localhost"), "localhost");
assert.equal(parseHostHeaderHostname("localhost:13773"), "localhost");
assert.equal(parseHostHeaderHostname("127.0.0.1:13773"), "127.0.0.1");
assert.equal(parseHostHeaderHostname("[::1]:13773"), "[::1]");
Comment thread
cursor[bot] marked this conversation as resolved.
assert.equal(parseHostHeaderHostname("example.com:8080"), "example.com");
assert.equal(parseHostHeaderHostname(" LOCALHOST:13773 "), "localhost");
});

it("returns null for missing or malformed host headers", () => {
assert.equal(parseHostHeaderHostname(undefined), null);
assert.equal(parseHostHeaderHostname(""), null);
assert.equal(parseHostHeaderHostname(" "), null);
assert.equal(parseHostHeaderHostname("exa mple.com"), null);
assert.equal(parseHostHeaderHostname("http://example.com"), null);
});
});

describe("isLoopbackHostHeader", () => {
it("accepts loopback host headers, with ports and brackets", () => {
assert.isTrue(isLoopbackHostHeader("localhost"));
assert.isTrue(isLoopbackHostHeader("localhost:13773"));
assert.isTrue(isLoopbackHostHeader("127.0.0.1:13773"));
assert.isTrue(isLoopbackHostHeader("127.5.5.5"));
assert.isTrue(isLoopbackHostHeader("127.5.5.5:8080"));
assert.isTrue(isLoopbackHostHeader("[::1]:13773"));
assert.isTrue(isLoopbackHostHeader("[::1]"));
});

it("rejects non-loopback and malformed host headers (fails closed)", () => {
assert.isFalse(isLoopbackHostHeader("evil.example"));
assert.isFalse(isLoopbackHostHeader("evil.example:13773"));
assert.isFalse(isLoopbackHostHeader("localhost.evil.example"));
assert.isFalse(isLoopbackHostHeader("10.0.0.5:13773"));
assert.isFalse(isLoopbackHostHeader("[::2]:13773"));
assert.isFalse(isLoopbackHostHeader(undefined));
assert.isFalse(isLoopbackHostHeader(""));
assert.isFalse(isLoopbackHostHeader("exa mple.com"));
});
});

describe("isLoopbackHost", () => {
it("keeps the config-host semantics: undefined means loopback", () => {
assert.isTrue(isLoopbackHost(undefined));
assert.isTrue(isLoopbackHost(""));
assert.isTrue(isLoopbackHost("localhost"));
assert.isTrue(isLoopbackHost("127.0.0.1"));
assert.isTrue(isLoopbackHost("127.99.0.1"));
assert.isTrue(isLoopbackHost("::1"));
assert.isTrue(isLoopbackHost("[::1]"));
assert.isFalse(isLoopbackHost("0.0.0.0"));
assert.isFalse(isLoopbackHost("192.168.1.10"));
});
});
Loading
Loading