export const CSRF_COOKIE = "local_studio_csrf";export const CSRF_HEADER = "x-local-studio-csrf";export const CSRF_BOOTSTRAP_HEADER = "x-local-studio-csrf-bootstrap"; export type RequestBoundaryInput = { method: string; host: string | null; forwardedHost: string | null; forwardedProto: string | null; origin: string | null; fetchSite: string | null; csrfCookie: string | null; csrfHeader: string | null; tailscaleUser: string | null; requestProtocol: string; allowedTailscaleHosts: string[]; allowedTailscaleUsers: string[]; csrfToken: string;}; export type RequestBoundaryResult = | { ok: true; remote: boolean } | { ok: false; status: 403 | 421; error: string }; function normalizedHost(value: string | null): string | null { const host = value?.trim().toLowerCase(); if (!host || host.includes(",") || /[\s/@\\]/.test(host)) return null; return host;} function hostname(value: string): string | null { try { return new URL(`http://${value}`).hostname.toLowerCase(); } catch { return null; }} export function isLoopbackHost(value: string): boolean { const name = hostname(value); return name === "localhost" || name === "127.0.0.1" || name === "::1";} function isMutation(method: string): boolean { return ["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase());} export function splitAllowedValues(value: string | undefined): string[] { return (value ?? "") .split(/[\s,]+/) .map((entry) => entry.trim().toLowerCase()) .filter(Boolean);} type HostDecision = | { readonly ok: false; readonly failure: RequestBoundaryResult } | { readonly ok: true; readonly effectiveHost: string; readonly remote: boolean }; /** Which host this request is really for, and whether it reached us from off-box. */function resolveHost(input: RequestBoundaryInput): HostDecision { const host = normalizedHost(input.host); const forwardedHost = input.forwardedHost ? normalizedHost(input.forwardedHost) : null; const allowedHosts = new Set(input.allowedTailscaleHosts.map((entry) => entry.toLowerCase())); const allowed = (candidate: string) => isLoopbackHost(candidate) || allowedHosts.has(candidate); if (!host || !allowed(host)) { return { ok: false, failure: { ok: false, status: 421, error: "Host is not allowed" } }; } if (input.forwardedHost && !forwardedHost) { return { ok: false, failure: { ok: false, status: 421, error: "Forwarded host is invalid" } }; } if (forwardedHost && !allowed(forwardedHost)) { return { ok: false, failure: { ok: false, status: 421, error: "Forwarded host is not allowed" }, }; } const effectiveHost = forwardedHost ?? host; return { ok: true, effectiveHost, remote: !isLoopbackHost(effectiveHost) };} /** Tailscale identity gate. Only applies to remote callers, and only when an * allowlist is configured. */function rejectedUser(input: RequestBoundaryInput, remote: boolean): RequestBoundaryResult | null { if (!remote || input.allowedTailscaleUsers.length === 0) return null; const allowedUsers = new Set(input.allowedTailscaleUsers.map((entry) => entry.toLowerCase())); const user = input.tailscaleUser?.trim().toLowerCase() ?? ""; if (allowedUsers.has(user)) return null; return { ok: false, status: 403, error: "Tailscale user is not allowed" };} /** The Origin header must name the same host and scheme we are serving. */function rejectedOrigin( input: RequestBoundaryInput, effectiveHost: string, remote: boolean,): RequestBoundaryResult | null { if (!input.origin) return null; const protocol = remote ? input.forwardedProto?.split(",")[0]?.trim().toLowerCase() || "https" : input.requestProtocol.replace(/:$/, "").toLowerCase(); try { const origin = new URL(input.origin); if (origin.host.toLowerCase() !== effectiveHost || origin.protocol !== `${protocol}:`) { return { ok: false, status: 403, error: "Origin is not allowed" }; } } catch { return { ok: false, status: 403, error: "Origin is invalid" }; } return null;} /** CSRF only defends against a *browser* replaying ambient credentials from * another origin. Our own server-to-server callers (the pi tool extensions: * browser_*) POST from Node with no cookie jar and no origin, so they * could never satisfy a double-submit token — and every browser attaches at * least one of Origin/Sec-Fetch-Site to a cross-origin mutation, so the * absence of both is proof this is not a browser. Skipping the token check * there is what makes the agent's own tools work; the host allowlist, * cross-site rejection and the access-token guard still apply. */function rejectedCsrf(input: RequestBoundaryInput): RequestBoundaryResult | null { const fromBrowser = Boolean(input.origin) || Boolean(input.fetchSite); if (!fromBrowser) return null; if (input.csrfCookie === input.csrfToken && input.csrfHeader === input.csrfToken) return null; return { ok: false, status: 403, error: "CSRF validation failed" };} export function evaluateRequestBoundary(input: RequestBoundaryInput): RequestBoundaryResult { const hostDecision = resolveHost(input); if (!hostDecision.ok) return hostDecision.failure; const { effectiveHost, remote } = hostDecision; const userRejection = rejectedUser(input, remote); if (userRejection) return userRejection; if (!isMutation(input.method)) return { ok: true, remote }; if (input.fetchSite?.toLowerCase() === "cross-site") { return { ok: false, status: 403, error: "Cross-site mutation rejected" }; } return ( rejectedOrigin(input, effectiveHost, remote) ?? rejectedCsrf(input) ?? { ok: true, remote } );}
export const CSRF_COOKIE = "local_studio_csrf";
export const CSRF_HEADER = "x-local-studio-csrf";
export const CSRF_BOOTSTRAP_HEADER = "x-local-studio-csrf-bootstrap";
export type RequestBoundaryInput = {
method: string;
host: string | null;
forwardedHost: string | null;
forwardedProto: string | null;
origin: string | null;
fetchSite: string | null;
csrfCookie: string | null;
csrfHeader: string | null;
tailscaleUser: string | null;
requestProtocol: string;
allowedTailscaleHosts: string[];
allowedTailscaleUsers: string[];
csrfToken: string;
};
export type RequestBoundaryResult =
| { ok: true; remote: boolean }
| { ok: false; status: 403 | 421; error: string };
function normalizedHost(value: string | null): string | null {
const host = value?.trim().toLowerCase();
if (!host || host.includes(",") || /[\s/@\\]/.test(host)) return null;
return host;
}
function hostname(value: string): string | null {
try {
return new URL(`http://${value}`).hostname.toLowerCase();
} catch {
return null;
}
}
export function isLoopbackHost(value: string): boolean {
const name = hostname(value);
return name === "localhost" || name === "127.0.0.1" || name === "::1";
}
function isMutation(method: string): boolean {
return ["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase());
}
export function splitAllowedValues(value: string | undefined): string[] {
return (value ?? "")
.split(/[\s,]+/)
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
type HostDecision =
| { readonly ok: false; readonly failure: RequestBoundaryResult }
| { readonly ok: true; readonly effectiveHost: string; readonly remote: boolean };
/** Which host this request is really for, and whether it reached us from off-box. */
function resolveHost(input: RequestBoundaryInput): HostDecision {
const host = normalizedHost(input.host);
const forwardedHost = input.forwardedHost ? normalizedHost(input.forwardedHost) : null;
const allowedHosts = new Set(input.allowedTailscaleHosts.map((entry) => entry.toLowerCase()));
const allowed = (candidate: string) => isLoopbackHost(candidate) || allowedHosts.has(candidate);
if (!host || !allowed(host)) {
return { ok: false, failure: { ok: false, status: 421, error: "Host is not allowed" } };
}
if (input.forwardedHost && !forwardedHost) {
return { ok: false, failure: { ok: false, status: 421, error: "Forwarded host is invalid" } };
}
if (forwardedHost && !allowed(forwardedHost)) {
return {
ok: false,
failure: { ok: false, status: 421, error: "Forwarded host is not allowed" },
};
}
const effectiveHost = forwardedHost ?? host;
return { ok: true, effectiveHost, remote: !isLoopbackHost(effectiveHost) };
}
/** Tailscale identity gate. Only applies to remote callers, and only when an
* allowlist is configured. */
function rejectedUser(input: RequestBoundaryInput, remote: boolean): RequestBoundaryResult | null {
if (!remote || input.allowedTailscaleUsers.length === 0) return null;
const allowedUsers = new Set(input.allowedTailscaleUsers.map((entry) => entry.toLowerCase()));
const user = input.tailscaleUser?.trim().toLowerCase() ?? "";
if (allowedUsers.has(user)) return null;
return { ok: false, status: 403, error: "Tailscale user is not allowed" };
}
/** The Origin header must name the same host and scheme we are serving. */
function rejectedOrigin(
input: RequestBoundaryInput,
effectiveHost: string,
remote: boolean,
): RequestBoundaryResult | null {
if (!input.origin) return null;
const protocol = remote
? input.forwardedProto?.split(",")[0]?.trim().toLowerCase() || "https"
: input.requestProtocol.replace(/:$/, "").toLowerCase();
try {
const origin = new URL(input.origin);
if (origin.host.toLowerCase() !== effectiveHost || origin.protocol !== `${protocol}:`) {
return { ok: false, status: 403, error: "Origin is not allowed" };
}
} catch {
return { ok: false, status: 403, error: "Origin is invalid" };
}
return null;
}
/** CSRF only defends against a *browser* replaying ambient credentials from
* another origin. Our own server-to-server callers (the pi tool extensions:
* browser_*) POST from Node with no cookie jar and no origin, so they
* could never satisfy a double-submit token — and every browser attaches at
* least one of Origin/Sec-Fetch-Site to a cross-origin mutation, so the
* absence of both is proof this is not a browser. Skipping the token check
* there is what makes the agent's own tools work; the host allowlist,
* cross-site rejection and the access-token guard still apply. */
function rejectedCsrf(input: RequestBoundaryInput): RequestBoundaryResult | null {
const fromBrowser = Boolean(input.origin) || Boolean(input.fetchSite);
if (!fromBrowser) return null;
if (input.csrfCookie === input.csrfToken && input.csrfHeader === input.csrfToken) return null;
return { ok: false, status: 403, error: "CSRF validation failed" };
}
export function evaluateRequestBoundary(input: RequestBoundaryInput): RequestBoundaryResult {
const hostDecision = resolveHost(input);
if (!hostDecision.ok) return hostDecision.failure;
const { effectiveHost, remote } = hostDecision;
const userRejection = rejectedUser(input, remote);
if (userRejection) return userRejection;
if (!isMutation(input.method)) return { ok: true, remote };
if (input.fetchSite?.toLowerCase() === "cross-site") {
return { ok: false, status: 403, error: "Cross-site mutation rejected" };
}
return (
rejectedOrigin(input, effectiveHost, remote) ?? rejectedCsrf(input) ?? { ok: true, remote }
);
}