144 lines · 5.5 KB · TypeScript
RawDownload
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 }  );}