Skip to content
Closed
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
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@
- Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it.
- Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete.

## 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
Loading
Loading