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
6 changes: 6 additions & 0 deletions .changeset/tidy-brokers-validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@call-e/core": patch
---

Validate broker session IDs, secrets, and login URLs for their cache, request,
header, and browser-opening sinks before persisting pending authentication state.
7 changes: 7 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion packages/core/lib/broker-client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { JsonObject, PendingLoginDocument, TokenDocument } from "./cache.js

export interface BrokerRequestConfig {
brokerBaseUrl: string;
authBaseUrl?: string;
timeoutSeconds: number;
integrationHeader?: string;
}
Expand Down Expand Up @@ -85,7 +86,10 @@ export function exchangeBrokerSession(
options?: BrokerRequestOptions,
): Promise<TokenDocument>;

export function normalizePendingSession(sessionPayload: BrokerSessionPayload): PendingLoginDocument;
export function normalizePendingSession(
sessionPayload: BrokerSessionPayload,
config?: BrokerRequestConfig,
): PendingLoginDocument;

export function ensurePendingLogin(
config: BrokerLoginConfig,
Expand Down
126 changes: 109 additions & 17 deletions packages/core/lib/broker-client.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -26,15 +26,98 @@ 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,
session_id: status.session_id || existing.session_id,
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,
});
}, config);
}

async function reconcileExistingPending(config, existing, { fetchImpl = globalThis.fetch } = {}) {
Expand All @@ -43,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) &&
Expand Down Expand Up @@ -82,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: String(sessionPayload.session_id),
session_secret: String(sessionPayload.session_secret),
login_url: String(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,
Expand All @@ -112,19 +196,27 @@ 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) {
removeFile(pendingPath);
}

const sessionPayload = await createBrokerSession(config, { fetchImpl });
const pending = normalizePendingSession(sessionPayload);
const pending = normalizePendingSession(sessionPayload, config);
writePrivateJson(pendingPath, pending);
return { pending, created: true };
}
Expand Down
Loading