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
21 changes: 21 additions & 0 deletions .changeset/otc-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@executor-js/local-app": patch
"@executor-js/cli": patch
"@executor-js/react": patch
---

fix: replace the token-in-URL web bootstrap with a one-time-code exchange

Opening the web UI previously put the daemon bearer token in the URL
(`?_token=<token>`) and the SPA persisted it to localStorage — both are
leak-prone surfaces (browser history, logs, screen recordings, and
localStorage is readable by any script on the origin).

`executor web` / `executor open` now mint a one-time code (bearer-gated,
single-use, 60-second TTL, 128-bit entropy, bound to the running daemon
instance) and open `/?_otc=<code>`. On first load the SPA exchanges the
code for the bearer, applies it to the in-memory connection, and strips the
query. The server also sets an HttpOnly SameSite=strict cookie as transport
hardening. Nothing is written to localStorage by the bootstrap path; the
legacy `?_token=` query is still accepted for compatibility with older
daemons but is no longer persisted.
33 changes: 31 additions & 2 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,7 +1149,10 @@ const runForegroundSession = (input: {

try {
console.log(`Executor is ready.`);
console.log(`Open: ${baseUrl}/?_token=${server.authToken}`);
const otcCode = server.otcStore?.issue() ?? null;
console.log(
`Open: ${otcCode ? `${baseUrl}/?_otc=${otcCode}` : `${baseUrl}/?_token=${server.authToken}`}`,
);
console.log(`Web: ${baseUrl}`);
console.log(`MCP: ${baseUrl}/mcp`);
console.log(`OpenAPI: ${baseUrl}/api/docs`);
Expand Down Expand Up @@ -3269,11 +3272,37 @@ const openRunningLocalWebApp = (): Effect.Effect<
}
const { origin, auth } = manifest.connection;
const token = auth?.kind === "bearer" ? auth.token : undefined;
const url = token ? `${origin}/?_token=${token}` : origin;
if (!token) {
console.log(`Opening ${origin}`);
yield* openInBrowser(origin);
return;
}
// Mint a one-time bootstrap code instead of putting the bearer in the
// URL. The browser exchanges it for the bearer on first load (HttpOnly
// cookie + in-memory connection), and the query is stripped.
const otc = yield* mintOtcForDaemon(origin, token);
const url = otc ? `${origin}/?_otc=${otc}` : `${origin}/?_token=${token}`;
console.log(`Opening ${url}`);
yield* openInBrowser(url);
});

/** Mint a one-time bootstrap code from the running daemon (bearer-gated).
* Falls back to null on any failure — the caller then falls back to the
* legacy `?_token=` URL rather than failing the open. */
const mintOtcForDaemon = (origin: string, token: string): Effect.Effect<string | null> =>
Effect.tryPromise({
try: async () => {
const res = await fetch(`${origin}/api/auth/otc`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const body = (await res.json()) as { readonly code?: unknown };
return typeof body.code === "string" && body.code.length > 0 ? body.code : null;
},
catch: () => null,
}).pipe(Effect.catch(() => Effect.succeed(null)));

/**
* `executor open` — the friendly way back in. Reads the running local server's
* manifest and opens the browser straight to its `?_token=` URL, so the user
Expand Down
139 changes: 139 additions & 0 deletions apps/local/src/otc-exchange.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { startServer, type ServerInstance } from "./serve";
import { OTC_TTL_MS, makeOtcStore } from "./otc";

let clientDir: string;
let dataDir: string;
let server: ServerInstance | null = null;

const TOKEN = "test-bearer-token";

const testHandlers = () => ({
api: {
handler: async () => new Response("ok"),
dispose: async () => {},
},
mcp: {
handleRequest: async () => new Response("ok"),
handleApprovalRequest: async () => new Response("ok"),
handlePausedRequest: async () => new Response("ok"),
close: async () => {},
},
});

const startTestServer = async (): Promise<string> => {
server = await startServer({
port: 0,
hostname: "127.0.0.1",
clientDir,
authToken: TOKEN,
handlers: testHandlers(),
});
return `http://127.0.0.1:${server.port}`;
};

beforeEach(() => {
clientDir = mkdtempSync(join(tmpdir(), "exec-otc-serve-"));
dataDir = mkdtempSync(join(tmpdir(), "exec-otc-data-"));
process.env.EXECUTOR_DATA_DIR = dataDir;
process.env.EXECUTOR_SCOPE_DIR = dataDir;
writeFileSync(
join(clientDir, "index.html"),
"<!doctype html><html><body>index-shell</body></html>",
);
});

afterEach(async () => {
if (server) {
await server.stop();
server = null;
}
delete process.env.EXECUTOR_DATA_DIR;
delete process.env.EXECUTOR_SCOPE_DIR;
rmSync(clientDir, { recursive: true, force: true });
rmSync(dataDir, { recursive: true, force: true });
});

describe("OTC exchange endpoint", () => {
it("mints a code via the bearer-gated route and exchanges it once (200 + HttpOnly cookie)", async () => {
const origin = await startTestServer();
const mint = await fetch(`${origin}/api/auth/otc`, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(mint.status).toBe(200);
const { code } = (await mint.json()) as { code: string };
expect(code.length).toBeGreaterThanOrEqual(16); // ≥128 bits base64url

const exchange = await fetch(`${origin}/api/auth/exchange`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: `code=${encodeURIComponent(code)}`,
});
expect(exchange.status).toBe(200);
const body = (await exchange.json()) as { token: string };
expect(body.token).toBe(TOKEN);

const setCookie = exchange.headers.get("set-cookie") ?? "";
expect(setCookie).toContain("executor_session");
expect(setCookie).toContain("HttpOnly");
expect(setCookie).toContain("SameSite=Strict");
});

it("rejects a replayed code (single-use — second exchange is 400)", async () => {
const origin = await startTestServer();
const mint = await fetch(`${origin}/api/auth/otc`, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}` },
});
const { code } = (await mint.json()) as { code: string };

const first = await fetch(`${origin}/api/auth/exchange`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: `code=${encodeURIComponent(code)}`,
});
expect(first.status).toBe(200);

const replay = await fetch(`${origin}/api/auth/exchange`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: `code=${encodeURIComponent(code)}`,
});
expect(replay.status).toBe(400);
});

it("rejects an unknown code", async () => {
const origin = await startTestServer();
const res = await fetch(`${origin}/api/auth/exchange`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "code=never-issued",
});
expect(res.status).toBe(400);
});

it("rejects the mint route without a bearer", async () => {
const origin = await startTestServer();
const res = await fetch(`${origin}/api/auth/otc`, { method: "POST" });
expect(res.status).toBe(401);
});

it("rejects an expired code (TTL honored by the store)", () => {
let now = 1_000;
const store = makeOtcStore(() => now);
const code = store.issue();
expect(store.consume(code)).toBe(code);

// Re-issue after expiry — the consumed code must stay dead even after
// pruning.
const code2 = store.issue();
now = now + OTC_TTL_MS + 1;
expect(store.consume(code2)).toBeNull();
expect(store.consume(code)).toBeNull();
});
});
79 changes: 79 additions & 0 deletions apps/local/src/otc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// ---------------------------------------------------------------------------
// OtcStore — one-time codes for the web bootstrap exchange.
//
// The local daemon's bearer token is the single credential gating every
// surface. The web bootstrap previously shipped it in the URL (`?_token=`)
// and persisted it to localStorage — both are XSS/leak-adjacent surfaces
// (browser history, logs, screen recordings, localStorage read by any script
// on the origin). The OTC flow replaces the URL-token with a one-time code:
//
// 1. `executor web` / `executor open` mints a code from the running daemon
// (bearer-gated endpoint; the CLI already holds the bearer).
// 2. The browser loads `/?_otc=<code>`, POSTs it to the unauthenticated
// `/api/auth/exchange` endpoint, and receives the bearer in the response
// body PLUS an HttpOnly SameSite=strict cookie (transport hardening —
// the cookie is not the request gate, the bearer is; see serve-shared
// makeIsAuthorized).
// 3. The client applies the bearer to the in-memory connection and strips
// the query. Nothing is written to localStorage.
//
// Codes are single-use, TTL-bounded (≤60s), high-entropy (≥128 bits), bound
// to the daemon instance (the in-memory map dies with the process, so a code
// can never be replayed against a future daemon generation), and never
// logged.
// ---------------------------------------------------------------------------

import { randomBytes } from "node:crypto";

export const OTC_TTL_MS = 60 * 1000;
const OTC_ENTROPY_BYTES = 16; // 128 bits

interface OtcEntry {
readonly code: string;
readonly expiresAt: number;
}

export interface OtcStore {
/** Mint a single-use code valid for OTC_TTL_MS. */
readonly issue: () => string;
/**
* Consume a code. Returns the code's id on success (after which the code is
* dead), or null if the code is unknown, already consumed, or expired.
* Consumption is destructive: a consumed code can never be redeemed again.
*/
readonly consume: (code: string) => string | null;
}

/** In-memory OTC store. Instance-bound by construction. */
export const makeOtcStore = (now: () => number = Date.now): OtcStore => {
const codes = new Map<string, OtcEntry>();

const pruneExpired = (): void => {
const t = now();
for (const [code, entry] of codes) {
if (entry.expiresAt <= t) codes.delete(code);
}
};

return {
issue: () => {
pruneExpired();
// Collision odds are negligible at 128 bits, but loop anyway so a
// pathological collision can never silently clobber a live code.
let code = randomBytes(OTC_ENTROPY_BYTES).toString("base64url");
while (codes.has(code)) {
code = randomBytes(OTC_ENTROPY_BYTES).toString("base64url");
}
codes.set(code, { code, expiresAt: now() + OTC_TTL_MS });
return code;
},

consume: (code) => {
pruneExpired();
const entry = codes.get(code);
if (entry === undefined) return null;
codes.delete(code);
return entry.expiresAt > now() ? entry.code : null;
},
};
};
50 changes: 49 additions & 1 deletion apps/local/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Subprocess } from "bun";
import { setOAuthCompletionListener } from "@executor-js/api";
import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";
import { loadOrMintLocalAuthToken } from "./auth";
import { makeOtcStore, type OtcStore } from "./otc";
import { publishOAuthResult, waitForOAuthResult } from "./oauth-result-store";
import { disposeAnalytics } from "./analytics";
import { startIntegrationsRefresh } from "./integrations";
Expand Down Expand Up @@ -276,6 +277,8 @@ export interface ServerInstance {
/** The effective bearer token this server validates. Callers publish it in the
* manifest, print the `?_token=` bootstrap URL, and hand it to MCP clients. */
authToken: string;
/** One-time bootstrap-code store for the web OTC exchange. */
otcStore: OtcStore;
stop: () => Promise<void>;
}

Expand Down Expand Up @@ -338,6 +341,9 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
// process could otherwise drive.
const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();
const isAuthorized = makeIsAuthorized(authToken);
// One-time bootstrap codes (web OTC exchange). Instance-bound: dies with
// the process, so a code can never be replayed against a future daemon.
const otcStore = makeOtcStore();
// CORS-only origin allowlist (no Host gate — the bearer is the boundary).
const corsAllowedHosts = new Set<string>([
...DEFAULT_ALLOWED_HOSTS,
Expand Down Expand Up @@ -425,6 +431,46 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));
}

// The OTC mint is bearer-gated (the CLI holds the bearer from the
// manifest); the exchange is reached by the browser on FIRST load,
// before it has any bearer — same rationale as the OAuth callback: an
// external actor (here, the just-opened browser tab) cannot carry our
// bearer. The one-time code IS the credential; consumption is
// destructive.
if (url.pathname === "/api/auth/otc" && req.method === "POST") {
if (!isAuthorized(req)) {
return withCors(new Response("Unauthorized", { status: 401 }));
}
return withCors(
new Response(JSON.stringify({ code: otcStore.issue() }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}
if (url.pathname === "/api/auth/exchange" && req.method === "POST") {
// oxlint-disable-next-line executor/no-promise-catch -- boundary: raw web-handler request body read; an unreadable body collapses to no code, which the exchange rejects
const code = (await req.text().catch(() => ""))
.split("&")
.find((kv) => kv.startsWith("code="))
?.slice("code=".length);
const redeemed = code ? otcStore.consume(code) : null;
if (redeemed === null) {
return withCors(new Response("Invalid or expired code", { status: 400 }));
}
// The bearer is the request gate for /api; the HttpOnly cookie is
// transport hardening (SameSite=strict, never readable by JS).
return withCors(
new Response(JSON.stringify({ token: authToken }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": `executor_session=${authToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800`,
},
}),
);
}

// OAuth callbacks and CIMD documents are reached by the external
// provider, which cannot carry our local bearer. Everything else under
// /api and /mcp requires the bearer.
Expand Down Expand Up @@ -524,6 +570,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
return {
port: server.port!,
authToken,
otcStore,
async stop() {
if (stopped) return;
stopped = true;
Expand All @@ -540,5 +587,6 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server

if (import.meta.main) {
const server = await startServer();
console.log(`Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`);
const otc = server.otcStore.issue();
console.log(`Executor listening on http://localhost:${server.port}/?_otc=${otc}`);
}
Loading
Loading