From a1d014a043d54fc832721c06ecd85ed24a99a054 Mon Sep 17 00:00:00 2001 From: MauriceMohr <257130077+mauricemohr88-debug@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:24:57 +0200 Subject: [PATCH 1/3] fix(core): validate required broker session fields --- .changeset/tidy-brokers-validate.md | 5 +++++ packages/core/lib/broker-client.js | 14 ++++++++++--- packages/core/test/core.test.js | 32 +++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 .changeset/tidy-brokers-validate.md diff --git a/.changeset/tidy-brokers-validate.md b/.changeset/tidy-brokers-validate.md new file mode 100644 index 0000000..1a762b7 --- /dev/null +++ b/.changeset/tidy-brokers-validate.md @@ -0,0 +1,5 @@ +--- +"@call-e/core": patch +--- + +Reject incomplete broker login session responses before persisting pending authentication state. diff --git a/packages/core/lib/broker-client.js b/packages/core/lib/broker-client.js index 11c1323..1ab990b 100644 --- a/packages/core/lib/broker-client.js +++ b/packages/core/lib/broker-client.js @@ -37,6 +37,14 @@ function pendingFromBrokerStatus(existing, status) { }); } +function requiredSessionField(sessionPayload, field) { + const value = sessionPayload?.[field]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Broker session response is missing required ${field}`); + } + return value; +} + async function reconcileExistingPending(config, existing, { fetchImpl = globalThis.fetch } = {}) { if (!existing?.session_id) { return null; @@ -99,9 +107,9 @@ export async function exchangeBrokerSession(config, pending, { fetchImpl = globa export function normalizePendingSession(sessionPayload) { return { - session_id: String(sessionPayload.session_id), - session_secret: String(sessionPayload.session_secret), - login_url: String(sessionPayload.login_url), + session_id: requiredSessionField(sessionPayload, "session_id"), + session_secret: requiredSessionField(sessionPayload, "session_secret"), + login_url: requiredSessionField(sessionPayload, "login_url"), status: String(sessionPayload.status || "PENDING").toUpperCase(), created_at: new Date().toISOString(), expires_at: sessionPayload.expires_at ? String(sessionPayload.expires_at) : null, diff --git a/packages/core/test/core.test.js b/packages/core/test/core.test.js index 6fccdb9..f58ad93 100644 --- a/packages/core/test/core.test.js +++ b/packages/core/test/core.test.js @@ -171,6 +171,38 @@ test("broker client sends integration headers and normalizes pending sessions", assert.ok(Date.parse(pending.created_at)); }); +test("broker client rejects a malformed created session before caching it", async () => { + const cacheRoot = makeTempRoot("calle-core-malformed-broker-session"); + const config = { + cacheRoot, + brokerBaseUrl: "https://broker.test", + serverUrl: "https://broker.test/mcp/openagent_oauth", + authBaseUrl: "https://broker.test", + channel: "openagent_oauth", + scope: "openid email profile", + clientName: "calle Login", + timeoutSeconds: 15, + }; + const pendingPath = pendingCachePath(cacheRoot, config.serverUrl); + const validSession = { + session_id: "session-1", + session_secret: "secret-1", + login_url: "https://broker.test/openagent-auth/sessions/session-1/start", + }; + + for (const field of Object.keys(validSession)) { + for (const invalidValue of [undefined, null, 42, " "]) { + await assert.rejects( + ensurePendingLogin(config, { + fetchImpl: async () => jsonResponse({ ...validSession, [field]: invalidValue }), + }), + new RegExp(`Broker session response is missing required ${field}`), + ); + assert.equal(fs.existsSync(pendingPath), false, `${field}=${String(invalidValue)}`); + } + } +}); + test("broker client refreshes active pending login against broker before reuse", async () => { const cacheRoot = makeTempRoot("calle-core-pending-reuse"); const config = { From 18bc3c8e57500cbac57c9f989c45ffeb668f6ced Mon Sep 17 00:00:00 2001 From: MauriceMohr Date: Fri, 11 Sep 2026 08:19:22 +0200 Subject: [PATCH 2/3] fix(core): harden broker session validation --- .changeset/tidy-brokers-validate.md | 3 +- packages/core/README.md | 7 + packages/core/lib/broker-client.d.ts | 6 +- packages/core/lib/broker-client.js | 134 +++++++++++++---- packages/core/test/core.test.js | 208 ++++++++++++++++++++++++++- 5 files changed, 330 insertions(+), 28 deletions(-) diff --git a/.changeset/tidy-brokers-validate.md b/.changeset/tidy-brokers-validate.md index 1a762b7..0425d7a 100644 --- a/.changeset/tidy-brokers-validate.md +++ b/.changeset/tidy-brokers-validate.md @@ -2,4 +2,5 @@ "@call-e/core": patch --- -Reject incomplete broker login session responses before persisting pending authentication state. +Validate broker session IDs, secrets, and login URLs for their cache, request, +header, and browser-opening sinks before persisting pending authentication state. diff --git a/packages/core/README.md b/packages/core/README.md index 7524ef2..6401541 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -87,6 +87,13 @@ Operator-facing integrations can use `ensurePendingLogin` when they need to show the login URL and its remaining lifetime without blocking on the full login flow. +Broker session fields are validated before they reach the pending-login cache, +browser opener, request path, or session-secret header. HTTPS login URLs must +use the configured `brokerBaseUrl` or `authBaseUrl` origin. HTTP is accepted +only when the URL is a loopback address on that exact configured origin, which +keeps local development support without letting a remote broker redirect a +client to an arbitrary local service. + ## Outbound Call Contract CALL-E's outbound tools follow this order: diff --git a/packages/core/lib/broker-client.d.ts b/packages/core/lib/broker-client.d.ts index d175da0..019a6bd 100644 --- a/packages/core/lib/broker-client.d.ts +++ b/packages/core/lib/broker-client.d.ts @@ -2,6 +2,7 @@ import type { JsonObject, PendingLoginDocument, TokenDocument } from "./cache.js export interface BrokerRequestConfig { brokerBaseUrl: string; + authBaseUrl?: string; timeoutSeconds: number; integrationHeader?: string; } @@ -85,7 +86,10 @@ export function exchangeBrokerSession( options?: BrokerRequestOptions, ): Promise; -export function normalizePendingSession(sessionPayload: BrokerSessionPayload): PendingLoginDocument; +export function normalizePendingSession( + sessionPayload: BrokerSessionPayload, + config?: BrokerRequestConfig, +): PendingLoginDocument; export function ensurePendingLogin( config: BrokerLoginConfig, diff --git a/packages/core/lib/broker-client.js b/packages/core/lib/broker-client.js index 1ab990b..192f542 100644 --- a/packages/core/lib/broker-client.js +++ b/packages/core/lib/broker-client.js @@ -1,5 +1,5 @@ import { pendingCachePath, pendingIsExpired, readPendingLogin, removeFile, tokenCachePath, tokenIsUsable, writePrivateJson, readJson } from "./cache.js"; -import { INTEGRATION_HEADER, SESSION_SECRET_HEADER } from "./constants.js"; +import { DEFAULT_BASE_URL, INTEGRATION_HEADER, SESSION_SECRET_HEADER } from "./constants.js"; import { HttpStatusError, requestJson } from "./http.js"; function integrationHeaders(config) { @@ -26,7 +26,90 @@ function isTerminalBrokerSessionStatus(status) { return status === "EXPIRED" || status === "FAILED" || status === "EXCHANGED"; } -function pendingFromBrokerStatus(existing, status) { +const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/; +const HEADER_VALUE = /^[\x21-\x7E]+$/; +const MAX_LOGIN_URL_LENGTH = 2048; +const MAX_SESSION_ID_LENGTH = 512; +const MAX_SESSION_SECRET_LENGTH = 1024; + +function sessionValidationError(message) { + const error = new Error(message); + error.code = "INVALID_BROKER_SESSION"; + return error; +} + +function requiredSessionString(sessionPayload, field, maxLength) { + const value = sessionPayload?.[field]; + if (typeof value !== "string" || !value.trim()) { + throw sessionValidationError(`Broker session response is missing required ${field}`); + } + if (value.length > maxLength || CONTROL_CHARACTERS.test(value)) { + throw sessionValidationError(`Broker session response has invalid ${field}`); + } + return value; +} + +function isLoopbackHost(hostname) { + return hostname === "localhost" || hostname === "::1" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname); +} + +function trustedLoginOrigins(config) { + const origins = new Set(); + const configuredOrigins = config === undefined + ? [DEFAULT_BASE_URL] + : [config.brokerBaseUrl, config.authBaseUrl]; + for (const value of configuredOrigins) { + if (!value) continue; + try { + origins.add(new URL(value).origin); + } catch { + throw sessionValidationError("Broker session configuration has an invalid trusted origin"); + } + } + if (origins.size === 0) { + throw sessionValidationError("Broker session configuration has no trusted origin"); + } + return origins; +} + +function validateLoginUrl(config, sessionPayload) { + const value = requiredSessionString(sessionPayload, "login_url", MAX_LOGIN_URL_LENGTH); + let parsed; + try { + parsed = new URL(value); + } catch { + throw sessionValidationError("Broker session response has invalid login_url"); + } + if (parsed.username || parsed.password) { + throw sessionValidationError("Broker session response has invalid login_url"); + } + const allowedOrigins = trustedLoginOrigins(config); + if (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname) && allowedOrigins.has(parsed.origin)) { + return parsed.toString(); + } + if (parsed.protocol !== "https:" || !allowedOrigins.has(parsed.origin)) { + throw sessionValidationError("Broker session response has invalid login_url"); + } + return parsed.toString(); +} + +function validatePendingSession(config, sessionPayload) { + const sessionId = requiredSessionString(sessionPayload, "session_id", MAX_SESSION_ID_LENGTH); + if (sessionId === "." || sessionId === ".." || !/^[A-Za-z0-9._~:+-]+$/.test(sessionId)) { + throw sessionValidationError("Broker session response has invalid session_id"); + } + const sessionSecret = requiredSessionString(sessionPayload, "session_secret", MAX_SESSION_SECRET_LENGTH); + if (!HEADER_VALUE.test(sessionSecret)) { + throw sessionValidationError("Broker session response has invalid session_secret"); + } + return { + session_id: sessionId, + session_secret: sessionSecret, + login_url: validateLoginUrl(config, sessionPayload), + }; +} + +function pendingFromBrokerStatus(config, existing, status) { return normalizePendingSession({ ...existing, ...status, @@ -34,15 +117,7 @@ function pendingFromBrokerStatus(existing, status) { session_secret: status.session_secret || existing.session_secret, login_url: status.login_url || status.auth_url || status.verification_url || existing.login_url, expires_at: status.expires_at || existing.expires_at, - }); -} - -function requiredSessionField(sessionPayload, field) { - const value = sessionPayload?.[field]; - if (typeof value !== "string" || !value.trim()) { - throw new Error(`Broker session response is missing required ${field}`); - } - return value; + }, config); } async function reconcileExistingPending(config, existing, { fetchImpl = globalThis.fetch } = {}) { @@ -51,7 +126,7 @@ async function reconcileExistingPending(config, existing, { fetchImpl = globalTh } try { const brokerStatus = await getBrokerSessionStatus(config, existing, { fetchImpl }); - const reconciled = pendingFromBrokerStatus(existing, brokerStatus); + const reconciled = pendingFromBrokerStatus(config, existing, brokerStatus); if ( reconciled && isActivePendingStatus(reconciled.status) && @@ -90,26 +165,27 @@ export async function createBrokerSession(config, { fetchImpl = globalThis.fetch } export async function getBrokerSessionStatus(config, pending, { fetchImpl = globalThis.fetch } = {}) { - return requestJson("GET", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions/${pending.session_id}`, { + const safePending = validatePendingSession(config, pending); + return requestJson("GET", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions/${encodeURIComponent(safePending.session_id)}`, { fetchImpl, timeoutSeconds: config.timeoutSeconds, - headers: brokerHeaders(config, pending.session_secret), + headers: brokerHeaders(config, safePending.session_secret), }); } export async function exchangeBrokerSession(config, pending, { fetchImpl = globalThis.fetch } = {}) { - return requestJson("POST", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions/${pending.session_id}/exchange`, { + const safePending = validatePendingSession(config, pending); + return requestJson("POST", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions/${encodeURIComponent(safePending.session_id)}/exchange`, { fetchImpl, timeoutSeconds: config.timeoutSeconds, - headers: brokerHeaders(config, pending.session_secret), + headers: brokerHeaders(config, safePending.session_secret), }); } -export function normalizePendingSession(sessionPayload) { +export function normalizePendingSession(sessionPayload, config) { + const safeSession = validatePendingSession(config, sessionPayload); return { - session_id: requiredSessionField(sessionPayload, "session_id"), - session_secret: requiredSessionField(sessionPayload, "session_secret"), - login_url: requiredSessionField(sessionPayload, "login_url"), + ...safeSession, status: String(sessionPayload.status || "PENDING").toUpperCase(), created_at: new Date().toISOString(), expires_at: sessionPayload.expires_at ? String(sessionPayload.expires_at) : null, @@ -120,11 +196,19 @@ export function normalizePendingSession(sessionPayload) { export async function ensurePendingLogin(config, { fetchImpl = globalThis.fetch, forceLogin = false } = {}) { const pendingPath = pendingCachePath(config.cacheRoot, config.serverUrl); - const existing = readPendingLogin(pendingPath); + let existing = readPendingLogin(pendingPath); if (!forceLogin && existing && isActivePendingStatus(existing.status) && !pendingIsExpired(existing)) { - const reconciled = await reconcileExistingPending(config, existing, { fetchImpl }); - if (reconciled) { - return { pending: reconciled, created: false }; + try { + const reconciled = await reconcileExistingPending(config, existing, { fetchImpl }); + if (reconciled) { + return { pending: reconciled, created: false }; + } + } catch (error) { + if (error?.code !== "INVALID_BROKER_SESSION") { + throw error; + } + removeFile(pendingPath); + existing = null; } } if (existing) { @@ -132,7 +216,7 @@ export async function ensurePendingLogin(config, { fetchImpl = globalThis.fetch, } const sessionPayload = await createBrokerSession(config, { fetchImpl }); - const pending = normalizePendingSession(sessionPayload); + const pending = normalizePendingSession(sessionPayload, config); writePrivateJson(pendingPath, pending); return { pending, created: true }; } diff --git a/packages/core/test/core.test.js b/packages/core/test/core.test.js index f58ad93..12c6d74 100644 --- a/packages/core/test/core.test.js +++ b/packages/core/test/core.test.js @@ -5,6 +5,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + DEFAULT_BASE_URL, INTEGRATION_HEADER, MCP_PROTOCOL_VERSION, SESSION_SECRET_HEADER, @@ -27,6 +28,8 @@ import { import { createBrokerSession, ensurePendingLogin, + exchangeBrokerSession, + getBrokerSessionStatus, loginWithBroker, normalizePendingSession, } from "@call-e/core/broker-client"; @@ -162,7 +165,7 @@ test("broker client sends integration headers and normalizes pending sessions", }; const session = await createBrokerSession(config, { fetchImpl }); - const pending = normalizePendingSession(session); + const pending = normalizePendingSession(session, config); assert.equal(pending.session_id, "session-1"); assert.equal(pending.session_secret, "secret-1"); assert.equal(pending.status, "PENDING"); @@ -203,6 +206,209 @@ test("broker client rejects a malformed created session before caching it", asyn } }); +test("broker client rejects hostile created session values before cache, output, or browser opening", async () => { + const cacheRoot = makeTempRoot("calle-core-hostile-created-session"); + const config = { + cacheRoot, + brokerBaseUrl: "https://broker.test", + serverUrl: "https://broker.test/mcp/openagent_oauth", + authBaseUrl: "https://broker.test", + channel: "openagent_oauth", + scope: "openid email profile", + clientName: "calle Login", + timeoutSeconds: 15, + }; + const pendingPath = pendingCachePath(cacheRoot, config.serverUrl); + const validSession = { + session_id: "session-1", + session_secret: "secret-1", + login_url: "https://broker.test/openagent-auth/sessions/session-1/start", + }; + const hostileValues = { + login_url: "javascript:alert(1)", + session_id: "../exchange?x=", + session_secret: "ok\r\nX-Evil: 1", + }; + + for (const [field, hostileValue] of Object.entries(hostileValues)) { + const printed = []; + const opened = []; + const stop = new Error("stop after unsafe session handling"); + await assert.rejects( + loginWithBroker(config, { + fetchImpl: async () => jsonResponse({ ...validSession, [field]: hostileValue }), + stderr: (line) => printed.push(line), + openBrowser: async (url) => opened.push(url), + sleepImpl: async () => { throw stop; }, + noBrowserOpen: false, + }), + /Broker session response has invalid/, + ); + assert.equal(fs.existsSync(pendingPath), false, `${field} was not cached`); + assert.deepEqual(printed, [], `${field} was not printed`); + assert.deepEqual(opened, [], `${field} was not opened`); + } +}); + +test("broker client allows HTTP login URLs only for the configured loopback origin", () => { + const session = { + session_id: "session-1", + session_secret: "secret-1", + login_url: "http://127.0.0.1:8787/openagent-auth/sessions/session-1/start", + }; + assert.throws( + () => normalizePendingSession(session, { + brokerBaseUrl: "https://broker.test", + authBaseUrl: "https://auth.test", + }), + /Broker session response has invalid login_url/, + ); + assert.equal( + normalizePendingSession(session, { + brokerBaseUrl: "http://127.0.0.1:8787", + authBaseUrl: "https://auth.test", + }).login_url, + session.login_url, + ); + assert.equal( + normalizePendingSession({ + ...session, + login_url: `${DEFAULT_BASE_URL}/openagent-auth/sessions/session-1/start`, + }).login_url, + `${DEFAULT_BASE_URL}/openagent-auth/sessions/session-1/start`, + ); + assert.equal( + normalizePendingSession({ + ...session, + login_url: "http://[::1]:8787/openagent-auth/sessions/session-1/start", + }, { + brokerBaseUrl: "http://[::1]:8787", + authBaseUrl: "https://auth.test", + }).login_url, + "http://[::1]:8787/openagent-auth/sessions/session-1/start", + ); +}); + +test("broker client rejects non-header session secrets before caching or requests", async () => { + const cacheRoot = makeTempRoot("calle-core-non-header-session-secret"); + const config = { + cacheRoot, + brokerBaseUrl: "https://broker.test", + serverUrl: "https://broker.test/mcp/openagent_oauth", + authBaseUrl: "https://broker.test", + channel: "openagent_oauth", + scope: "openid email profile", + clientName: "calle Login", + timeoutSeconds: 15, + }; + const pendingPath = pendingCachePath(cacheRoot, config.serverUrl); + for (const sessionSecret of ["ok\u009B", "emoji🔒"]) { + await assert.rejects( + ensurePendingLogin(config, { + fetchImpl: async () => jsonResponse({ + session_id: "session-1", + session_secret: sessionSecret, + login_url: "https://broker.test/openagent-auth/sessions/start", + }), + }), + /Broker session response has invalid session_secret/, + ); + assert.equal(fs.existsSync(pendingPath), false, "unsafe header value was not cached"); + } +}); + +test("broker client rejects dot-only session IDs before cache or broker request sinks", async () => { + const cacheRoot = makeTempRoot("calle-core-dot-session-id"); + const config = { + cacheRoot, + brokerBaseUrl: "https://broker.test", + serverUrl: "https://broker.test/mcp/openagent_oauth", + authBaseUrl: "https://broker.test", + channel: "openagent_oauth", + scope: "openid email profile", + clientName: "calle Login", + timeoutSeconds: 15, + }; + const pendingPath = pendingCachePath(cacheRoot, config.serverUrl); + for (const sessionId of [".", ".."]) { + await assert.rejects( + ensurePendingLogin(config, { + fetchImpl: async () => jsonResponse({ + session_id: sessionId, + session_secret: "safe-secret", + login_url: "https://broker.test/openagent-auth/sessions/start", + }), + }), + /Broker session response has invalid session_id/, + ); + assert.equal(fs.existsSync(pendingPath), false, `${sessionId} was not cached`); + const pending = { + session_id: sessionId, + session_secret: "safe-secret", + login_url: "https://broker.test/openagent-auth/sessions/start", + }; + for (const request of [getBrokerSessionStatus, exchangeBrokerSession]) { + await assert.rejects( + request(config, pending, { fetchImpl: async () => { throw new Error("network must not run"); } }), + /Broker session response has invalid session_id/, + ); + } + } +}); + +test("broker client removes hostile cached sessions before reuse and encodes opaque path segments", async () => { + const cacheRoot = makeTempRoot("calle-core-hostile-cached-session"); + const config = { + cacheRoot, + brokerBaseUrl: "https://broker.test", + serverUrl: "https://broker.test/mcp/openagent_oauth", + authBaseUrl: "https://broker.test", + channel: "openagent_oauth", + scope: "openid email profile", + clientName: "calle Login", + timeoutSeconds: 15, + }; + const pendingPath = pendingCachePath(cacheRoot, config.serverUrl); + writePrivateJson(pendingPath, { + session_id: "../exchange?x=", + session_secret: "ok\r\nX-Evil: 1", + login_url: "javascript:alert(1)", + status: "PENDING", + created_at: "2026-01-01T00:00:00.000Z", + expires_at: "2030-01-01T00:00:00.000Z", + }); + const requests = []; + const freshSession = { + session_id: "fresh-session", + session_secret: "fresh-secret", + login_url: "https://broker.test/openagent-auth/sessions/fresh-session/start", + }; + const result = await ensurePendingLogin(config, { + fetchImpl: async (url, init) => { + requests.push({ url, headers: init.headers, method: init.method }); + return jsonResponse(freshSession); + }, + }); + assert.equal(result.created, true); + assert.deepEqual(requests.map(({ method, url }) => `${method} ${url}`), [ + "POST https://broker.test/api/v1/openagent-auth/sessions", + ]); + assert.equal(requests[0].headers[SESSION_SECRET_HEADER], undefined); + + const opaquePending = { + session_id: "opaque:id", + session_secret: "safe-secret", + login_url: "https://broker.test/openagent-auth/sessions/opaque/start", + }; + await getBrokerSessionStatus(config, opaquePending, { + fetchImpl: async (url, init) => { + assert.equal(url, "https://broker.test/api/v1/openagent-auth/sessions/opaque%3Aid"); + assert.equal(init.headers[SESSION_SECRET_HEADER], "safe-secret"); + return jsonResponse({ status: "PENDING" }); + }, + }); +}); + test("broker client refreshes active pending login against broker before reuse", async () => { const cacheRoot = makeTempRoot("calle-core-pending-reuse"); const config = { From 4cb8691527acd9e9403168b7e3f64050b94ab44b Mon Sep 17 00:00:00 2001 From: MauriceMohr Date: Mon, 14 Sep 2026 05:11:15 +0200 Subject: [PATCH 3/3] fix(auth): preserve broker compatibility and validate examples --- .changeset/tidy-brokers-validate.md | 5 + examples/mcp-broker-client/README.md | 5 + examples/mcp-broker-client/python/README.md | 2 + examples/mcp-broker-client/python/client.py | 159 +++++++++++++++--- examples/mcp-broker-client/python/test_e2e.py | 134 +++++++++++++++ .../typescript-standalone/src/client.ts | 150 ++++++++++++++--- .../typescript-standalone/test/e2e.test.ts | 108 +++++++++++- .../typescript/src/client.ts | 31 +++- .../typescript/test/e2e.test.ts | 108 +++++++++++- packages/cli/docs/cli-reference.md | 5 + packages/cli/lib/cli.js | 19 ++- packages/cli/test/cli.test.js | 38 +++++ packages/core/README.md | 9 + packages/core/lib/broker-client.d.ts | 10 +- packages/core/lib/broker-client.js | 18 +- packages/core/test/core.test.js | 38 ++++- packages/core/test/types.ts | 14 +- 17 files changed, 773 insertions(+), 80 deletions(-) diff --git a/.changeset/tidy-brokers-validate.md b/.changeset/tidy-brokers-validate.md index 0425d7a..51525c9 100644 --- a/.changeset/tidy-brokers-validate.md +++ b/.changeset/tidy-brokers-validate.md @@ -1,6 +1,11 @@ --- "@call-e/core": patch +"@call-e/cli": patch --- Validate broker session IDs, secrets, and login URLs for their cache, request, header, and browser-opening sinks before persisting pending authentication state. +Preserve one-argument normalization for custom broker origins and safely encode +opaque session IDs, with an optional focused origin policy for callers handling +their own cache or display. +Suppress untrusted cached login URLs in CLI status and authorization guidance. diff --git a/examples/mcp-broker-client/README.md b/examples/mcp-broker-client/README.md index 371d917..be3aaf0 100644 --- a/examples/mcp-broker-client/README.md +++ b/examples/mcp-broker-client/README.md @@ -38,6 +38,11 @@ export MCP_LOG_FILE=/tmp/calle-mcp-example.log The log file receives the same JSON events printed to stdout, plus timestamps. Do not publish it because live runs may include a browser login URL. +The clients accept a pending login only when its browser URL uses HTTPS (or a +configured HTTP loopback origin) and its origin matches `MCP_BROKER_BASE_URL` +or `MCP_AUTH_BASE_URL`. Invalid cached or broker-provided session data is +discarded before it is logged, printed, or used in a broker request. + ## Plan Call Example `plan_call` creates a CALL-E call plan. It does not start the call; running the diff --git a/examples/mcp-broker-client/python/README.md b/examples/mcp-broker-client/python/README.md index 7c01863..5cc1ae5 100644 --- a/examples/mcp-broker-client/python/README.md +++ b/examples/mcp-broker-client/python/README.md @@ -10,3 +10,5 @@ uv run pytest The client uses `MCP_CACHE_ROOT` for token and pending-login cache files. It does not print access tokens, refresh tokens, or broker session secrets. +It accepts login URLs only from the configured broker or auth origin over HTTPS +or configured HTTP loopback, and discards invalid cached sessions before use. diff --git a/examples/mcp-broker-client/python/client.py b/examples/mcp-broker-client/python/client.py index 1d6e04e..70f117a 100644 --- a/examples/mcp-broker-client/python/client.py +++ b/examples/mcp-broker-client/python/client.py @@ -1,10 +1,12 @@ import asyncio import hashlib +import ipaddress import json import os from datetime import datetime, timezone from pathlib import Path from typing import Any +from urllib.parse import quote, urlparse import httpx @@ -14,6 +16,9 @@ DEFAULT_SCOPE = "openid email profile" DEFAULT_CLIENT_NAME = "calle Login" MCP_PROTOCOL_VERSION = "2025-11-25" +MAX_LOGIN_URL_LENGTH = 2048 +MAX_SESSION_ID_LENGTH = 512 +MAX_SESSION_SECRET_LENGTH = 1024 class McpHttpError(Exception): @@ -168,13 +173,108 @@ def token_is_usable(document: dict[str, Any] | None, min_ttl_seconds: float) -> return (expires_at - datetime.now(timezone.utc)).total_seconds() > min_ttl_seconds -def pending_is_valid(document: dict[str, Any] | None) -> bool: +def contains_controls(value: str) -> bool: + return any(ord(character) <= 0x1F or 0x7F <= ord(character) <= 0x9F for character in value) + + +def required_session_string(document: dict[str, Any], field: str, max_length: int) -> str: + value = document.get(field) + if not isinstance(value, str) or not value.strip() or len(value) > max_length or contains_controls(value): + raise ValueError(f"Broker session has invalid {field}") + try: + value.encode("utf-8") + except UnicodeEncodeError as error: + raise ValueError(f"Broker session has invalid {field}") from error + return value + + +def is_loopback_host(hostname: str | None) -> bool: + if hostname == "localhost": + return True + if not hostname: + return False + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + pass + parts = hostname.split(".") + return len(parts) == 4 and parts[0] == "127" and all(part.isdigit() and 0 <= int(part) <= 255 for part in parts) + + +def canonical_host(value: str) -> str: + try: + raw_host = httpx.URL(value).raw_host.decode("ascii") + except (httpx.InvalidURL, UnicodeError, ValueError) as error: + raise ValueError("Broker URL has an invalid host") from error + try: + return ipaddress.ip_address(raw_host).compressed + except ValueError: + return raw_host + + +def configured_origin(value: str) -> tuple[str, str, int | None]: + parsed = urlparse(value) + try: + port = parsed.port + except ValueError as error: + raise ValueError("Broker configuration has an invalid trusted origin") from error + if parsed.username or parsed.password or not parsed.hostname or (parsed.scheme != "https" and not (parsed.scheme == "http" and is_loopback_host(parsed.hostname))): + raise ValueError("Broker configuration has an invalid trusted origin") + normalized_port = port if port is not None else (443 if parsed.scheme == "https" else 80) + return (parsed.scheme, canonical_host(value), normalized_port) + + +def validate_broker_configuration(config: dict[str, Any]) -> None: + configured_origin(config["broker_base_url"]) + configured_origin(config["auth_base_url"]) + + +def validate_login_url(config: dict[str, Any], document: dict[str, Any]) -> str: + login_url = required_session_string(document, "login_url", MAX_LOGIN_URL_LENGTH) + parsed = urlparse(login_url) + try: + port = parsed.port + except ValueError as error: + raise ValueError("Broker session has invalid login_url") from error + trusted_origins = {configured_origin(config["broker_base_url"]), configured_origin(config["auth_base_url"])} + normalized_port = port if port is not None else (443 if parsed.scheme == "https" else 80) + origin = (parsed.scheme, canonical_host(login_url), normalized_port) + if parsed.username or parsed.password or not parsed.hostname or origin not in trusted_origins or (parsed.scheme != "https" and not (parsed.scheme == "http" and is_loopback_host(parsed.hostname))): + raise ValueError("Broker session has invalid login_url") + return login_url + + +def normalize_pending_login(document: dict[str, Any] | None, config: dict[str, Any]) -> dict[str, Any] | None: if not document: + return None + try: + session_id = required_session_string(document, "session_id", MAX_SESSION_ID_LENGTH) + if session_id in {".", ".."}: + raise ValueError("Broker session has invalid session_id") + session_secret = required_session_string(document, "session_secret", MAX_SESSION_SECRET_LENGTH) + if not all(0x21 <= ord(character) <= 0x7E for character in session_secret): + raise ValueError("Broker session has invalid session_secret") + status = required_session_string(document, "status", 128) + created_at = required_session_string(document, "created_at", 256) + return { + "session_id": session_id, + "session_secret": session_secret, + "login_url": validate_login_url(config, document), + "status": status.upper(), + "created_at": created_at, + "expires_at": document.get("expires_at") if isinstance(document.get("expires_at"), str) else None, + "error_message": document.get("error_message") if isinstance(document.get("error_message"), str) else None, + "poll_after_ms": int(document.get("poll_after_ms") or 0) or None, + } + except (TypeError, ValueError): + return None + + +def pending_is_valid(document: dict[str, Any] | None, config: dict[str, Any]) -> bool: + pending = normalize_pending_login(document, config) + if not pending: return False - for field in ("session_id", "session_secret", "login_url", "status", "created_at"): - if not isinstance(document.get(field), str) or not document[field]: - return False - expires_at = parse_iso_date(document.get("expires_at")) + expires_at = parse_iso_date(pending.get("expires_at")) if expires_at is None: return True if expires_at.tzinfo is None: @@ -183,6 +283,7 @@ def pending_is_valid(document: dict[str, Any] | None) -> bool: async def create_broker_session(client: httpx.AsyncClient, config: dict[str, Any]) -> dict[str, Any]: + validate_broker_configuration(config) response = await client.post( f"{config['broker_base_url']}/api/v1/openagent-auth/sessions", headers={"X-Call-E-Integration": config["integration_header"]}, @@ -196,23 +297,32 @@ async def create_broker_session(client: httpx.AsyncClient, config: dict[str, Any ) response.raise_for_status() payload = response.json() - return { - "session_id": str(payload["session_id"]), - "session_secret": str(payload["session_secret"]), - "login_url": str(payload["login_url"]), - "status": str(payload.get("status", "PENDING")).upper(), + if not isinstance(payload, dict): + raise RuntimeError("Broker response has an invalid session") + pending = normalize_pending_login({ + "session_id": payload.get("session_id"), + "session_secret": payload.get("session_secret"), + "login_url": payload.get("login_url"), + "status": payload.get("status", "PENDING"), "created_at": datetime.now(timezone.utc).isoformat(), "expires_at": payload.get("expires_at"), "error_message": None, "poll_after_ms": int(payload.get("poll_after_ms") or 0) or None, - } + }, config) + if not pending: + raise RuntimeError("Broker response has an invalid session") + return pending async def get_broker_status(client: httpx.AsyncClient, config: dict[str, Any], pending: dict[str, Any]) -> dict[str, Any]: + validate_broker_configuration(config) + safe_pending = normalize_pending_login(pending, config) + if not safe_pending: + raise RuntimeError("Broker session has invalid cached data") response = await client.get( - f"{config['broker_base_url']}/api/v1/openagent-auth/sessions/{pending['session_id']}", + f"{config['broker_base_url']}/api/v1/openagent-auth/sessions/{quote(safe_pending['session_id'], safe='')}", headers={ - "X-OpenAgent-Session-Secret": pending["session_secret"], + "X-OpenAgent-Session-Secret": safe_pending["session_secret"], "X-Call-E-Integration": config["integration_header"], }, ) @@ -221,10 +331,14 @@ async def get_broker_status(client: httpx.AsyncClient, config: dict[str, Any], p async def exchange_broker_session(client: httpx.AsyncClient, config: dict[str, Any], pending: dict[str, Any]) -> dict[str, Any]: + validate_broker_configuration(config) + safe_pending = normalize_pending_login(pending, config) + if not safe_pending: + raise RuntimeError("Broker session has invalid cached data") response = await client.post( - f"{config['broker_base_url']}/api/v1/openagent-auth/sessions/{pending['session_id']}/exchange", + f"{config['broker_base_url']}/api/v1/openagent-auth/sessions/{quote(safe_pending['session_id'], safe='')}/exchange", headers={ - "X-OpenAgent-Session-Secret": pending["session_secret"], + "X-OpenAgent-Session-Secret": safe_pending["session_secret"], "X-Call-E-Integration": config["integration_header"], }, ) @@ -245,9 +359,10 @@ async def ensure_broker_token(config: dict[str, Any]) -> dict[str, Any]: timeout = httpx.Timeout(config["timeout_seconds"]) async with httpx.AsyncClient(timeout=timeout) as client: - pending = read_json(pending_path) - if not pending_is_valid(pending): - if pending: + cached_pending = read_json(pending_path) + pending = normalize_pending_login(cached_pending, config) + if not pending_is_valid(pending, config): + if cached_pending: remove_file(pending_path) pending = await create_broker_session(client, config) write_private_json(pending_path, pending) @@ -258,13 +373,17 @@ async def ensure_broker_token(config: dict[str, Any]) -> dict[str, Any]: deadline = asyncio.get_running_loop().time() + config["poll_timeout_seconds"] while asyncio.get_running_loop().time() < deadline: status_payload = await get_broker_status(client, config, pending) - pending = { + pending = normalize_pending_login({ **pending, + **status_payload, "status": str(status_payload.get("status", pending.get("status", "PENDING"))).upper(), "expires_at": status_payload.get("expires_at", pending.get("expires_at")), "error_message": status_payload.get("error_message"), "poll_after_ms": int(status_payload.get("poll_after_ms") or pending.get("poll_after_ms") or 1), - } + }, config) + if not pending: + remove_file(pending_path) + raise RuntimeError("Broker response has an invalid session") write_private_json(pending_path, pending) emit("auth_poll", pending_status=pending["status"]) diff --git a/examples/mcp-broker-client/python/test_e2e.py b/examples/mcp-broker-client/python/test_e2e.py index e59369f..6b43d90 100644 --- a/examples/mcp-broker-client/python/test_e2e.py +++ b/examples/mcp-broker-client/python/test_e2e.py @@ -1,4 +1,5 @@ import hashlib +import asyncio import json import os import subprocess @@ -7,6 +8,10 @@ from pathlib import Path from urllib.request import Request, urlopen +import httpx +import pytest + +from client import create_broker_session, exchange_broker_session, get_broker_status, normalize_pending_login ROOT = Path(__file__).resolve().parents[2] EXAMPLE = Path(__file__).resolve().parent @@ -114,6 +119,18 @@ def assert_no_secrets(output): assert "stale-access-token" not in output +def broker_config(): + return { + "broker_base_url": "https://broker.test", + "auth_base_url": "https://broker.test", + "integration_header": "test", + "server_url": "https://broker.test/mcp", + "channel": "openagent_oauth", + "scope": "openid", + "client_name": "test", + } + + def test_broker_client_login_tool_resource_and_cached_reuse(): process, fake = start_fake_server(pending_first=True) try: @@ -247,3 +264,120 @@ def test_broker_client_clears_stale_cached_token_rejected_by_mcp_server(): assert any(request["has_bearer_token"] for request in state["mcp_requests"]) finally: stop_fake_server(process) + + +def test_broker_client_discards_hostile_cached_login_before_output_or_broker_request(): + process, fake = start_fake_server() + try: + cache_root = tempfile.mkdtemp(prefix="calle-broker-example-python-") + write_cache( + cache_root, + fake["server_url"], + "pending_login.json", + { + "session_id": "../exchange?unsafe=true", + "session_secret": "safe\r\nX-Evil: 1", + "login_url": "javascript:alert('unsafe')", + "status": "PENDING", + "created_at": "2026-01-01T00:00:00Z", + "expires_at": "2030-01-01T00:00:00Z", + }, + ) + result = run_client( + { + "MCP_BASE_URL": fake["base_url"], + "MCP_SERVER_URL": fake["server_url"], + "MCP_CACHE_ROOT": cache_root, + } + ) + + assert result.returncode == 0, result.stderr + assert "javascript:alert" not in result.stdout + result.stderr + assert "X-Evil" not in result.stdout + result.stderr + state = read_state(fake["state_url"]) + assert len(state["broker_creates"]) == 1 + assert state["broker_exchange_count"] == 1 + finally: + stop_fake_server(process) + + +@pytest.mark.parametrize( + "override", + [ + {"login_url": "https://untrusted.example/login"}, {"login_url": "javascript:alert(1)"}, + {"login_url": "https://\u200d.example/login"}, + {"session_secret": "safe\r\nX-Evil: 1"}, {"session_secret": "safe✓"}, + {"session_id": "safe\x00id"}, {"session_id": "safe\ud800"}, {"session_id": "a" * 513}, + {"session_id": None}, {"session_secret": None}, {"login_url": 1}, + ], +) +def test_broker_client_rejects_hostile_response_before_cache_or_output(override): + async def exercise(): + payload = { + "session_id": "safe-id", + "session_secret": "safe-secret", + "login_url": "https://broker.test/login", + **override, + } + + def handler(request): + return httpx.Response( + 201, + content=json.dumps(payload, ensure_ascii=True).encode("utf-8"), + headers={"content-type": "application/json"}, + ) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(RuntimeError, match="invalid session"): + await create_broker_session(client, broker_config()) + + asyncio.run(exercise()) + + +def test_broker_client_encodes_opaque_session_ids_before_request_sinks(): + async def exercise(): + observed = [] + + def handler(request): + observed.append((str(request.url), request.headers["X-OpenAgent-Session-Secret"])) + return httpx.Response(200, json={"status": "PENDING"}) + + pending = { + "session_id": "../exchange?x=✓", + "session_secret": "safe-secret", + "login_url": "https://broker.test/login", + "status": "PENDING", + "created_at": "2026-01-01T00:00:00Z", + } + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + await get_broker_status(client, broker_config(), pending) + await exchange_broker_session(client, broker_config(), pending) + + assert observed == [ + ("https://broker.test/api/v1/openagent-auth/sessions/..%2Fexchange%3Fx%3D%E2%9C%93", "safe-secret"), + ("https://broker.test/api/v1/openagent-auth/sessions/..%2Fexchange%3Fx%3D%E2%9C%93/exchange", "safe-secret"), + ] + + asyncio.run(exercise()) + + +@pytest.mark.parametrize( + ("broker_origin", "login_url"), + [ + ("https://bücher.example", "https://xn--bcher-kva.example/login"), + ("http://[0:0:0:0:0:0:0:1]", "http://[::1]/login"), + ], +) +def test_broker_client_accepts_equivalent_idn_and_ipv6_origins(broker_origin, login_url): + config = {**broker_config(), "broker_base_url": broker_origin, "auth_base_url": broker_origin} + assert normalize_pending_login( + { + "session_id": "safe-id", + "session_secret": "safe-secret", + "login_url": login_url, + "status": "PENDING", + "created_at": "2026-01-01T00:00:00Z", + }, + config, + ) is not None diff --git a/examples/mcp-broker-client/typescript-standalone/src/client.ts b/examples/mcp-broker-client/typescript-standalone/src/client.ts index ffa2276..e56e07f 100644 --- a/examples/mcp-broker-client/typescript-standalone/src/client.ts +++ b/examples/mcp-broker-client/typescript-standalone/src/client.ts @@ -12,6 +12,10 @@ const DEFAULT_MIN_TTL_SECONDS = 300; const MCP_PROTOCOL_VERSION = "2025-11-25"; const INTEGRATION_HEADER = "X-Call-E-Integration"; const SESSION_SECRET_HEADER = "X-OpenAgent-Session-Secret"; +const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u; +const MAX_LOGIN_URL_LENGTH = 2048; +const MAX_SESSION_ID_LENGTH = 512; +const MAX_SESSION_SECRET_LENGTH = 1024; type Config = { baseUrl: string; @@ -215,25 +219,94 @@ function tokenIsUsable(document: Record | null, minTtlSeconds: return expiresAt.getTime() - Date.now() > minTtlSeconds * 1000; } -function normalizePendingLogin(value: Record | null): PendingLogin | null { +function hasUnpairedSurrogate(value: string) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + if (index + 1 >= value.length) return true; + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true; + } + } + return false; +} + +function requiredSessionString(value: Record, field: string, maxLength: number) { + const candidate = value[field]; + if (typeof candidate !== "string" || !candidate.trim() || candidate.length > maxLength || CONTROL_CHARACTERS.test(candidate) || hasUnpairedSurrogate(candidate)) { + throw new Error(`Broker session has invalid ${field}.`); + } + return candidate; +} + +function isLoopbackHost(hostname: string) { + return hostname === "localhost" || hostname === "::1" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/u.test(hostname); +} + +function configuredOrigin(value: string) { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error("Broker configuration has an invalid trusted origin."); + } + if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHost(parsed.hostname)))) { + throw new Error("Broker configuration has an invalid trusted origin."); + } + return parsed.origin; +} + +function validateBrokerConfiguration(config: Config) { + configuredOrigin(config.brokerBaseUrl); + configuredOrigin(config.authBaseUrl); +} + +function validateLoginUrl(config: Config, value: Record) { + const loginUrl = requiredSessionString(value, "login_url", MAX_LOGIN_URL_LENGTH); + let parsed: URL; + try { + parsed = new URL(loginUrl); + } catch { + throw new Error("Broker session has invalid login_url."); + } + const trustedOrigins = new Set([configuredOrigin(config.brokerBaseUrl), configuredOrigin(config.authBaseUrl)]); + if (parsed.username || parsed.password || !trustedOrigins.has(parsed.origin) || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHost(parsed.hostname)))) { + throw new Error("Broker session has invalid login_url."); + } + return parsed.toString(); +} + +function normalizePendingLogin(value: Record | null, config: Config): PendingLogin | null { if (!value) { return null; } - for (const field of ["session_id", "session_secret", "login_url", "status", "created_at"]) { - if (typeof value[field] !== "string" || !value[field]) { - return null; + try { + const sessionId = requiredSessionString(value, "session_id", MAX_SESSION_ID_LENGTH); + if (sessionId === "." || sessionId === "..") { + throw new Error("Broker session has invalid session_id."); } + const sessionSecret = requiredSessionString(value, "session_secret", MAX_SESSION_SECRET_LENGTH); + if (!/^[\x21-\x7E]+$/u.test(sessionSecret)) { + throw new Error("Broker session has invalid session_secret."); + } + const status = requiredSessionString(value, "status", 128); + requiredSessionString(value, "created_at", 256); + return { + session_id: sessionId, + session_secret: sessionSecret, + login_url: validateLoginUrl(config, value), + status: status.toUpperCase(), + created_at: String(value.created_at), + expires_at: typeof value.expires_at === "string" ? value.expires_at : null, + error_message: typeof value.error_message === "string" ? value.error_message : null, + poll_after_ms: Number(value.poll_after_ms || 0) || null, + }; + } catch { + return null; } - return { - session_id: String(value.session_id), - session_secret: String(value.session_secret), - login_url: String(value.login_url), - status: String(value.status).toUpperCase(), - created_at: String(value.created_at), - expires_at: typeof value.expires_at === "string" ? value.expires_at : null, - error_message: typeof value.error_message === "string" ? value.error_message : null, - poll_after_ms: Number(value.poll_after_ms || 0) || null, - }; } function pendingIsExpired(pending: PendingLogin | null) { @@ -260,6 +333,7 @@ async function requestJson(method: string, url: string, { headers = {}, json }: } async function createBrokerSession(config: Config): Promise { + validateBrokerConfiguration(config); const payload = await requestJson("POST", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions`, { headers: { [INTEGRATION_HEADER]: config.integrationHeader }, json: { @@ -270,31 +344,45 @@ async function createBrokerSession(config: Config): Promise { client_name: config.clientName, }, }); - return { - session_id: String(payload.session_id), - session_secret: String(payload.session_secret), - login_url: String(payload.login_url), - status: String(payload.status || "PENDING").toUpperCase(), + const pending = normalizePendingLogin({ + session_id: payload.session_id, + session_secret: payload.session_secret, + login_url: payload.login_url, + status: payload.status ?? "PENDING", created_at: new Date().toISOString(), expires_at: typeof payload.expires_at === "string" ? payload.expires_at : null, error_message: null, poll_after_ms: Number(payload.poll_after_ms || 0) || null, - }; + }, config); + if (!pending) { + throw new Error("Broker response has an invalid session."); + } + return pending; } async function getBrokerStatus(config: Config, pending: PendingLogin) { - return requestJson("GET", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions/${pending.session_id}`, { + validateBrokerConfiguration(config); + const safePending = normalizePendingLogin(pending, config); + if (!safePending) { + throw new Error("Broker session has invalid cached data."); + } + return requestJson("GET", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions/${encodeURIComponent(safePending.session_id)}`, { headers: { - [SESSION_SECRET_HEADER]: pending.session_secret, + [SESSION_SECRET_HEADER]: safePending.session_secret, [INTEGRATION_HEADER]: config.integrationHeader, }, }); } async function exchangeBrokerSession(config: Config, pending: PendingLogin) { - return requestJson("POST", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions/${pending.session_id}/exchange`, { + validateBrokerConfiguration(config); + const safePending = normalizePendingLogin(pending, config); + if (!safePending) { + throw new Error("Broker session has invalid cached data."); + } + return requestJson("POST", `${config.brokerBaseUrl}/api/v1/openagent-auth/sessions/${encodeURIComponent(safePending.session_id)}/exchange`, { headers: { - [SESSION_SECRET_HEADER]: pending.session_secret, + [SESSION_SECRET_HEADER]: safePending.session_secret, [INTEGRATION_HEADER]: config.integrationHeader, }, }); @@ -316,9 +404,10 @@ async function ensureBrokerToken(config: Config): Promise): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { execFile( - tsxBin, - ["src/client.ts"], + process.execPath, + ["--import", tsxLoader, "src/client.ts"], { cwd: exampleRoot, env: { @@ -95,6 +96,31 @@ function tempCacheRoot() { return fs.mkdtempSync(path.join(os.tmpdir(), "calle-broker-standalone-ts-")); } +async function startBrokerProbe(session: Record) { + const paths: string[] = []; + const server = http.createServer((request, response) => { + paths.push(request.url || ""); + const send = (body: Record, status = 200) => { + const text = JSON.stringify(body); + response.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }); + response.end(text); + }; + if (request.method === "POST" && request.url === "/api/v1/openagent-auth/sessions") return send(session, 201); + if (request.method === "GET") return send({ status: "AUTHORIZED" }); + if (request.method === "POST" && request.url?.endsWith("/exchange")) return send({ token: { access_token: "probe-token" } }); + return send({ error: "unexpected" }, 404); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Broker probe did not bind a TCP port."); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + paths, + session, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + test("standalone broker example logs in, calls tool, reads resource, then reuses cache", async (t) => { const fake = await startFakeServer({ brokerPendingFirst: true }); t.after(() => fake.close()); @@ -217,3 +243,79 @@ test("standalone broker example clears stale cached token rejected by MCP server assert.equal(state.mcp_requests.some((request: { has_bearer_token: boolean }) => !request.has_bearer_token), true); assert.equal(state.mcp_requests.some((request: { has_bearer_token: boolean }) => request.has_bearer_token), true); }); + +test("standalone broker example discards hostile cached login data before output or broker requests", async (t) => { + const fake = await startFakeServer(); + t.after(() => fake.close()); + const cacheRoot = tempCacheRoot(); + writeCacheFile(cacheRoot, fake.serverUrl, "pending_login.json", { + session_id: "../exchange?unsafe=true", + session_secret: "safe\r\nX-Evil: 1", + login_url: "javascript:alert('unsafe')", + status: "PENDING", + created_at: "2026-01-01T00:00:00Z", + expires_at: "2030-01-01T00:00:00Z", + }); + + const result = await runClient({ + MCP_BASE_URL: fake.baseUrl, + MCP_SERVER_URL: fake.serverUrl, + MCP_CACHE_ROOT: cacheRoot, + }); + + assert.equal(result.code, 0, result.stderr); + assert.equal(`${result.stdout}\n${result.stderr}`.includes("javascript:alert"), false); + assert.equal(`${result.stdout}\n${result.stderr}`.includes("X-Evil"), false); + const state = await readState(fake.stateUrl); + assert.equal(state.broker_creates.length, 1); + assert.equal(state.broker_exchange_count, 1); +}); + +test("standalone broker example rejects hostile broker responses before caching or output", async (t) => { + const cases: Array<[string, (session: Record) => void]> = [ + ["foreign origin", (session) => { session.login_url = "https://untrusted.example/login"; }], + ["javascript URL", (session) => { session.login_url = "javascript:alert(1)"; }], + ["CRLF secret", (session) => { session.session_secret = "safe\r\nX-Evil: 1"; }], + ["Unicode secret", (session) => { session.session_secret = "safe✓"; }], + ["control ID", (session) => { session.session_id = "safe\u0000id"; }], + ["trailing high surrogate ID", (session) => { session.session_id = "safe\uD800"; }], + ["overlong ID", (session) => { session.session_id = "a".repeat(513); }], + ["missing ID", (session) => { delete session.session_id; }], + ["null secret", (session) => { session.session_secret = null; }], + ["numeric URL", (session) => { session.login_url = 1; }], + ]; + for (const [name, override] of cases) { + await t.test(name, async () => { + const session: Record = { session_id: "safe-id", session_secret: "safe-secret", login_url: "pending", status: "PENDING" }; + const probe = await startBrokerProbe(session); + try { + session.login_url = `${probe.baseUrl}/login`; + override(session); + const cacheRoot = tempCacheRoot(); + const result = await runClient({ MCP_BASE_URL: probe.baseUrl, MCP_SERVER_URL: `${probe.baseUrl}/mcp`, MCP_CACHE_ROOT: cacheRoot }); + assert.notEqual(result.code, 0); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /"status":"(?:login_required|pending)"/); + assert.equal(fs.existsSync(path.join(cacheDir(cacheRoot, `${probe.baseUrl}/mcp`), "pending_login.json")), false); + assert.deepEqual(probe.paths, ["/api/v1/openagent-auth/sessions"]); + } finally { await probe.close(); } + }); + } +}); + +test("standalone broker example encodes opaque session IDs at request paths", async (t) => { + const sessionId = "../exchange?x=✓"; + const probe = await startBrokerProbe({ session_id: sessionId, session_secret: "safe-secret", login_url: "pending", status: "PENDING" }); + t.after(() => probe.close()); + probe.session.login_url = `${probe.baseUrl}/login`; + const result = await runClient({ + MCP_BASE_URL: probe.baseUrl, + MCP_SERVER_URL: `${probe.baseUrl}/mcp`, + MCP_CACHE_ROOT: tempCacheRoot(), + MCP_AUTH_BASE_URL: probe.baseUrl, + }); + + assert.notEqual(result.code, 0); + const encoded = encodeURIComponent(sessionId); + assert.equal(probe.paths.includes(`/api/v1/openagent-auth/sessions/${encoded}`), true); + assert.equal(probe.paths.includes(`/api/v1/openagent-auth/sessions/${encoded}/exchange`), true); +}); diff --git a/examples/mcp-broker-client/typescript/src/client.ts b/examples/mcp-broker-client/typescript/src/client.ts index bd8a5d4..6ff5a81 100644 --- a/examples/mcp-broker-client/typescript/src/client.ts +++ b/examples/mcp-broker-client/typescript/src/client.ts @@ -24,6 +24,7 @@ import { ensurePendingLogin, exchangeBrokerSession, getBrokerSessionStatus, + normalizePendingSession, } from "@call-e/core/broker-client"; import { @@ -177,6 +178,20 @@ function clearBrokerState(config: Config) { removeFile(pendingCachePath(config.cacheRoot, config.serverUrl)); } +function normalizeCachedPending(config: Config, pending: ReturnType) { + if (!pending) { + return null; + } + try { + return normalizePendingSession(pending, { + brokerBaseUrl: config.brokerBaseUrl, + authBaseUrl: config.authBaseUrl, + }); + } catch { + return null; + } +} + async function ensureBrokerToken(config: Config): Promise> { const cachePath = tokenCachePath(config.cacheRoot, config.serverUrl); const pendingPath = pendingCachePath(config.cacheRoot, config.serverUrl); @@ -198,13 +213,16 @@ async function ensureBrokerToken(config: Config): Promise): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { execFile( - tsxBin, - ["src/client.ts"], + process.execPath, + ["--import", tsxLoader, "src/client.ts"], { cwd: exampleRoot, env: { @@ -95,6 +96,31 @@ function tempCacheRoot() { return fs.mkdtempSync(path.join(os.tmpdir(), "calle-broker-example-ts-")); } +async function startBrokerProbe(session: Record) { + const paths: string[] = []; + const server = http.createServer((request, response) => { + paths.push(request.url || ""); + const send = (body: Record, status = 200) => { + const text = JSON.stringify(body); + response.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }); + response.end(text); + }; + if (request.method === "POST" && request.url === "/api/v1/openagent-auth/sessions") return send(session, 201); + if (request.method === "GET") return send({ status: "AUTHORIZED" }); + if (request.method === "POST" && request.url?.endsWith("/exchange")) return send({ token: { access_token: "probe-token" } }); + return send({ error: "unexpected" }, 404); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Broker probe did not bind a TCP port."); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + paths, + session, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + test("broker example creates login, exchanges token, calls tool, reads resource, then reuses cache", async (t) => { const fake = await startFakeServer({ brokerPendingFirst: true }); t.after(() => fake.close()); @@ -217,3 +243,79 @@ test("broker example clears stale cached token rejected by MCP server", async (t assert.equal(state.mcp_requests.some((request: { has_bearer_token: boolean }) => !request.has_bearer_token), true); assert.equal(state.mcp_requests.some((request: { has_bearer_token: boolean }) => request.has_bearer_token), true); }); + +test("broker example discards hostile cached login data before output or broker requests", async (t) => { + const fake = await startFakeServer(); + t.after(() => fake.close()); + const cacheRoot = tempCacheRoot(); + writeCacheFile(cacheRoot, fake.serverUrl, "pending_login.json", { + session_id: "../exchange?unsafe=true", + session_secret: "safe\r\nX-Evil: 1", + login_url: "javascript:alert('unsafe')", + status: "PENDING", + created_at: "2026-01-01T00:00:00Z", + expires_at: "2030-01-01T00:00:00Z", + }); + + const result = await runClient({ + MCP_BASE_URL: fake.baseUrl, + MCP_SERVER_URL: fake.serverUrl, + MCP_CACHE_ROOT: cacheRoot, + }); + + assert.equal(result.code, 0, result.stderr); + assert.equal(`${result.stdout}\n${result.stderr}`.includes("javascript:alert"), false); + assert.equal(`${result.stdout}\n${result.stderr}`.includes("X-Evil"), false); + const state = await readState(fake.stateUrl); + assert.equal(state.broker_creates.length, 1); + assert.equal(state.broker_exchange_count, 1); +}); + +test("broker example rejects hostile broker responses before caching or output", async (t) => { + const cases: Array<[string, (session: Record) => void]> = [ + ["foreign origin", (session) => { session.login_url = "https://untrusted.example/login"; }], + ["javascript URL", (session) => { session.login_url = "javascript:alert(1)"; }], + ["CRLF secret", (session) => { session.session_secret = "safe\r\nX-Evil: 1"; }], + ["Unicode secret", (session) => { session.session_secret = "safe✓"; }], + ["control ID", (session) => { session.session_id = "safe\u0000id"; }], + ["trailing high surrogate ID", (session) => { session.session_id = "safe\uD800"; }], + ["overlong ID", (session) => { session.session_id = "a".repeat(513); }], + ["missing ID", (session) => { delete session.session_id; }], + ["null secret", (session) => { session.session_secret = null; }], + ["numeric URL", (session) => { session.login_url = 1; }], + ]; + for (const [name, override] of cases) { + await t.test(name, async () => { + const session: Record = { session_id: "safe-id", session_secret: "safe-secret", login_url: "pending", status: "PENDING" }; + const probe = await startBrokerProbe(session); + try { + session.login_url = `${probe.baseUrl}/login`; + override(session); + const cacheRoot = tempCacheRoot(); + const result = await runClient({ MCP_BASE_URL: probe.baseUrl, MCP_SERVER_URL: `${probe.baseUrl}/mcp`, MCP_CACHE_ROOT: cacheRoot }); + assert.notEqual(result.code, 0); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /"status":"(?:login_required|pending)"/); + assert.equal(fs.existsSync(path.join(cacheDir(cacheRoot, `${probe.baseUrl}/mcp`), "pending_login.json")), false); + assert.deepEqual(probe.paths, ["/api/v1/openagent-auth/sessions"]); + } finally { await probe.close(); } + }); + } +}); + +test("broker example encodes opaque session IDs at request paths", async (t) => { + const sessionId = "../exchange?x=✓"; + const probe = await startBrokerProbe({ session_id: sessionId, session_secret: "safe-secret", login_url: "pending", status: "PENDING" }); + t.after(() => probe.close()); + probe.session.login_url = `${probe.baseUrl}/login`; + const result = await runClient({ + MCP_BASE_URL: probe.baseUrl, + MCP_SERVER_URL: `${probe.baseUrl}/mcp`, + MCP_CACHE_ROOT: tempCacheRoot(), + MCP_AUTH_BASE_URL: probe.baseUrl, + }); + + assert.notEqual(result.code, 0); + const encoded = encodeURIComponent(sessionId); + assert.equal(probe.paths.includes(`/api/v1/openagent-auth/sessions/${encoded}`), true); + assert.equal(probe.paths.includes(`/api/v1/openagent-auth/sessions/${encoded}/exchange`), true); +}); diff --git a/packages/cli/docs/cli-reference.md b/packages/cli/docs/cli-reference.md index 07b28e6..e41d0c5 100644 --- a/packages/cli/docs/cli-reference.md +++ b/packages/cli/docs/cli-reference.md @@ -160,6 +160,11 @@ subcommand are rejected instead of being silently ignored. | `calle call status` | Query a call run through `get_call_run`. | `--run-id` | | `calle regions list` | Print the supported regions and languages documentation URL. | None | +`calle auth status` reports `pending_status` and `pending_login_url` as `null` +when the cached session fails validation against the configured broker/auth +origins. Authorization errors also omit untrusted cached login URLs and their +assistant hints. These read-only commands do not remove the cache file. + `calle regions list` is local and does not require authentication or call `plan_call`. It returns: diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 10bfce7..c43dc7c 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -22,7 +22,7 @@ import { CLI_VERSION, resolveRuntimeConfig, } from "./config.js"; -import { ensurePendingLogin, loginWithBroker } from "./broker-client.js"; +import { ensurePendingLogin, loginWithBroker, normalizePendingSession } from "./broker-client.js"; import { AuthRequiredError, McpHttpError, @@ -701,11 +701,22 @@ function publicLoginPayload({ config, cachePath, pendingPath, tokenDocument, sta }; } +function trustedPendingLogin(config, pending) { + if (!pending) return null; + try { + return normalizePendingSession(pending, config); + } catch (error) { + if (error?.code === "INVALID_BROKER_SESSION") return null; + throw error; + } +} + function statusPayload(config) { const cachePath = tokenCachePath(config.cacheRoot, config.serverUrl); const pendingPath = pendingCachePath(config.cacheRoot, config.serverUrl); const cacheDocument = readJson(cachePath); const pendingDocument = readJson(pendingPath); + const safePending = trustedPendingLogin(config, pendingDocument); return { server_url: config.serverUrl, cache_path: cachePath, @@ -714,8 +725,8 @@ function statusPayload(config) { pending_exists: pendingDocument !== null, usable: tokenIsUsable(cacheDocument, config.minTtlSeconds), expires_at: cacheDocument?.expires_at ?? null, - pending_status: pendingDocument?.status ?? null, - pending_login_url: pendingDocument?.login_url ?? null, + pending_status: safePending?.status ?? null, + pending_login_url: safePending?.login_url ?? null, }; } @@ -799,7 +810,7 @@ function isActivePendingLogin(pending) { } function authRequiredPayload(config, message = "A usable CALL-E auth token is required.") { - const pendingDocument = readPendingLogin(pendingCachePath(config.cacheRoot, config.serverUrl)); + const pendingDocument = trustedPendingLogin(config, readPendingLogin(pendingCachePath(config.cacheRoot, config.serverUrl))); const loginUrl = isActivePendingLogin(pendingDocument) ? pendingDocument.login_url : null; const assistantHint = preAuthAssistantHint(loginUrl); return { diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index 06f8401..3fdcb69 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -899,6 +899,44 @@ test("auth status reports missing, usable, and expired cache states", async () = assert.doesNotMatch(result.stdout, /secret-1/); }); +test("status and auth-required output never expose hostile cached broker sessions", async () => { + const cacheRoot = makeTempRoot("calle-cli-hostile-status"); + const baseUrl = "https://mcp.example"; + const serverUrl = `${baseUrl}/mcp/openagent_oauth`; + const pendingPath = pendingCachePath(cacheRoot, serverUrl); + const valid = { + session_id: "session-1", + session_secret: "safe-secret", + login_url: `${baseUrl}/login`, + status: "PENDING", + created_at: "2026-04-23T00:00:00Z", + }; + for (const invalid of [ + { login_url: "javascript:alert(1)" }, + { login_url: "https://untrusted.example/login" }, + { session_id: "session\nunsafe" }, + { session_secret: "secret\r\nX-Evil: 1" }, + ]) { + writePrivateJson(pendingPath, { ...valid, ...invalid }); + for (const command of [["auth", "status"], ["mcp", "tools"]]) { + const result = await run([...command, "--base-url", baseUrl, "--cache-root", cacheRoot], { + fetchImpl: async () => { throw new Error("invalid cache must not cause network access"); }, + }); + const payload = JSON.parse(result.stdout); + assert.equal(payload.login_url, undefined); + assert.equal(payload.assistant_hint, undefined); + assert.ok(payload.pending_login_url == null); + assert.doesNotMatch(result.stdout, /javascript:|untrusted\.example|X-Evil|safe-secret/); + if (command[0] === "auth") { + assert.equal(payload.pending_exists, true); + assert.equal(payload.pending_status, null); + } else { + assert.equal(payload.error.code, "auth_required"); + } + } + } +}); + test("auth logout removes token and pending cache", async () => { const cacheRoot = makeTempRoot("calle-cli-logout"); const serverUrl = "https://mcp.example/mcp/openagent_oauth"; diff --git a/packages/core/README.md b/packages/core/README.md index 6401541..7586adf 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -94,6 +94,15 @@ only when the URL is a loopback address on that exact configured origin, which keeps local development support without letting a remote broker redirect a client to an arbitrary local service. +`normalizePendingSession(payload)` remains usable with custom broker origins and +opaque IDs. Without a policy it checks field structure and safe URL schemes, but +cannot establish whether an origin is trusted. Before displaying or persisting +a session yourself, pass a `BrokerOriginPolicy` containing `brokerBaseUrl` and/or +`authBaseUrl`; this does not require request timeout settings. Core login and +request helpers always enforce their configured origins. IDs are length-bounded +and encoded as a single request-path segment rather than restricted to an ASCII +allowlist. + ## Outbound Call Contract CALL-E's outbound tools follow this order: diff --git a/packages/core/lib/broker-client.d.ts b/packages/core/lib/broker-client.d.ts index 019a6bd..f9e7aa0 100644 --- a/packages/core/lib/broker-client.d.ts +++ b/packages/core/lib/broker-client.d.ts @@ -1,8 +1,12 @@ import type { JsonObject, PendingLoginDocument, TokenDocument } from "./cache.js"; -export interface BrokerRequestConfig { - brokerBaseUrl: string; +export interface BrokerOriginPolicy { + brokerBaseUrl?: string; authBaseUrl?: string; +} + +export interface BrokerRequestConfig extends BrokerOriginPolicy { + brokerBaseUrl: string; timeoutSeconds: number; integrationHeader?: string; } @@ -88,7 +92,7 @@ export function exchangeBrokerSession( export function normalizePendingSession( sessionPayload: BrokerSessionPayload, - config?: BrokerRequestConfig, + originPolicy?: BrokerOriginPolicy, ): PendingLoginDocument; export function ensurePendingLogin( diff --git a/packages/core/lib/broker-client.js b/packages/core/lib/broker-client.js index 192f542..7c7576f 100644 --- a/packages/core/lib/broker-client.js +++ b/packages/core/lib/broker-client.js @@ -1,5 +1,5 @@ import { pendingCachePath, pendingIsExpired, readPendingLogin, removeFile, tokenCachePath, tokenIsUsable, writePrivateJson, readJson } from "./cache.js"; -import { DEFAULT_BASE_URL, INTEGRATION_HEADER, SESSION_SECRET_HEADER } from "./constants.js"; +import { INTEGRATION_HEADER, SESSION_SECRET_HEADER } from "./constants.js"; import { HttpStatusError, requestJson } from "./http.js"; function integrationHeaders(config) { @@ -55,9 +55,7 @@ function isLoopbackHost(hostname) { function trustedLoginOrigins(config) { const origins = new Set(); - const configuredOrigins = config === undefined - ? [DEFAULT_BASE_URL] - : [config.brokerBaseUrl, config.authBaseUrl]; + const configuredOrigins = [config.brokerBaseUrl, config.authBaseUrl]; for (const value of configuredOrigins) { if (!value) continue; try { @@ -83,11 +81,10 @@ function validateLoginUrl(config, sessionPayload) { if (parsed.username || parsed.password) { throw sessionValidationError("Broker session response has invalid login_url"); } - const allowedOrigins = trustedLoginOrigins(config); - if (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname) && allowedOrigins.has(parsed.origin)) { - return parsed.toString(); - } - if (parsed.protocol !== "https:" || !allowedOrigins.has(parsed.origin)) { + const safeScheme = parsed.protocol === "https:" || (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname)); + // Normalization alone has no origin context; every effectful core caller supplies its policy. + const allowedOrigins = config === undefined ? null : trustedLoginOrigins(config); + if (!safeScheme || (allowedOrigins && !allowedOrigins.has(parsed.origin))) { throw sessionValidationError("Broker session response has invalid login_url"); } return parsed.toString(); @@ -95,7 +92,8 @@ function validateLoginUrl(config, sessionPayload) { function validatePendingSession(config, sessionPayload) { const sessionId = requiredSessionString(sessionPayload, "session_id", MAX_SESSION_ID_LENGTH); - if (sessionId === "." || sessionId === ".." || !/^[A-Za-z0-9._~:+-]+$/.test(sessionId)) { + // Opaque IDs are encoded at request sinks; dot-only segments are normalized by URL parsers. + if (sessionId === "." || sessionId === ".." || !sessionId.isWellFormed()) { throw sessionValidationError("Broker session response has invalid session_id"); } const sessionSecret = requiredSessionString(sessionPayload, "session_secret", MAX_SESSION_SECRET_LENGTH); diff --git a/packages/core/test/core.test.js b/packages/core/test/core.test.js index 12c6d74..8bde20a 100644 --- a/packages/core/test/core.test.js +++ b/packages/core/test/core.test.js @@ -206,6 +206,38 @@ test("broker client rejects a malformed created session before caching it", asyn } }); +test("normalization preserves custom origins and opaque IDs while request sinks enforce their policy", async () => { + const config = { brokerBaseUrl: "https://custom-broker.test", timeoutSeconds: 15 }; + for (const sessionId of ["opaque/id?key=value#fragment", "../exchange?x=", "%2e%2e", "session ü 🔑"]) { + const session = { + session_id: sessionId, + session_secret: "safe-secret", + login_url: "https://custom-broker.test/login", + }; + const pending = normalizePendingSession(session); + assert.equal(pending.login_url, session.login_url); + assert.equal(pending.session_id, sessionId); + assert.equal(normalizePendingSession(session, { brokerBaseUrl: config.brokerBaseUrl }).session_id, sessionId); + + for (const request of [getBrokerSessionStatus, exchangeBrokerSession]) { + await request(config, pending, { + fetchImpl: async (url, init) => { + const suffix = request === exchangeBrokerSession ? "/exchange" : ""; + const expectedPath = `/api/v1/openagent-auth/sessions/${encodeURIComponent(sessionId)}${suffix}`; + assert.equal(new URL(url).pathname, expectedPath); + assert.equal(new URL(url).search, ""); + assert.equal(new URL(url).hash, ""); + assert.equal(init.headers[SESSION_SECRET_HEADER], "safe-secret"); + return jsonResponse({ status: "PENDING" }); + }, + }); + await assert.rejects(request({ brokerBaseUrl: "https://different-broker.test", timeoutSeconds: 15 }, pending, { + fetchImpl: async () => { throw new Error("untrusted origin reached a request"); }, + }), /Broker session response has invalid login_url/); + } + } +}); + test("broker client rejects hostile created session values before cache, output, or browser opening", async () => { const cacheRoot = makeTempRoot("calle-core-hostile-created-session"); const config = { @@ -226,7 +258,7 @@ test("broker client rejects hostile created session values before cache, output, }; const hostileValues = { login_url: "javascript:alert(1)", - session_id: "../exchange?x=", + session_id: "session\nunsafe", session_secret: "ok\r\nX-Evil: 1", }; @@ -317,7 +349,7 @@ test("broker client rejects non-header session secrets before caching or request } }); -test("broker client rejects dot-only session IDs before cache or broker request sinks", async () => { +test("broker client rejects unsafe or oversized session IDs before cache or broker request sinks", async () => { const cacheRoot = makeTempRoot("calle-core-dot-session-id"); const config = { cacheRoot, @@ -330,7 +362,7 @@ test("broker client rejects dot-only session IDs before cache or broker request timeoutSeconds: 15, }; const pendingPath = pendingCachePath(cacheRoot, config.serverUrl); - for (const sessionId of [".", ".."]) { + for (const sessionId of [".", "..", "id\u009B", "id\uD800", "id\uDC00", "x".repeat(513)]) { await assert.rejects( ensurePendingLogin(config, { fetchImpl: async () => jsonResponse({ diff --git a/packages/core/test/types.ts b/packages/core/test/types.ts index 58e8356..7690ea9 100644 --- a/packages/core/test/types.ts +++ b/packages/core/test/types.ts @@ -3,8 +3,9 @@ import { loginWithBroker, tokenIsUsable, type BrokerLoginConfig, + type BrokerOriginPolicy, } from "@call-e/core"; -import { ensurePendingLogin } from "@call-e/core/broker-client"; +import { ensurePendingLogin, normalizePendingSession } from "@call-e/core/broker-client"; import { readJson } from "@call-e/core/cache"; import { resolveServerUrl } from "@call-e/core/config"; import { DEFAULT_CHANNEL } from "@call-e/core/constants"; @@ -34,6 +35,17 @@ interface PlanCallResult { } async function consumePublicTypes() { + const session = { + session_id: "opaque/session ü", + session_secret: "safe-secret", + login_url: "https://custom-broker.test/login", + }; + const originPolicy: BrokerOriginPolicy = { brokerBaseUrl: "https://custom-broker.test" }; + normalizePendingSession(session).session_id.toUpperCase(); + normalizePendingSession(session, originPolicy).login_url.toUpperCase(); + normalizePendingSession(session, { authBaseUrl: "https://custom-broker.test" }); + normalizePendingSession(session, config); + const cached = readJson("/tmp/token.json"); if (tokenIsUsable(cached, config.minTtlSeconds)) { cached.token.access_token.toUpperCase();