import { INGEST_PREFIX } from "~/lib/analytics"; import { analyticsConfig } from "./analytics"; const MAX_BODY_BYTES = 5 * 1024 * 1024; const FORWARDED_REQUEST_HEADERS = ["content-type", "content-encoding", "accept", "accept-encoding", "user-agent"]; const DROPPED_RESPONSE_HEADERS = ["set-cookie", "access-control-allow-origin", "access-control-allow-credentials"]; /** PostHog serves the browser library's lazily loaded extensions from a separate assets host. */ export function ingestUpstream(host: string, path: string): string { const assets = /^\/(static|array)\//.test(path); const match = /^https:\/\/(us|eu)\.i\.posthog\.com$/.exec(host); if (assets && match) return `https://${match[1]}-assets.i.posthog.com`; return host; } /** Separators and dot segments, raw or percent-encoded in any case, that could re-point the upstream URL. */ const UNSAFE_PATH = /\/\/|\\|%2f|%5c|%2e|(^|\/)\.\.?(\/|$)/i; /** The path after /ingest, or null when it is not a plain path PostHog could serve. */ export function ingestPath(rawPath: string): string | null { const rest = rawPath.slice(INGEST_PREFIX.length) || "/"; if (!rest.startsWith("/") || UNSAFE_PATH.test(rest)) return null; return rest; } /** Fixed upstream origin plus a vetted path; null unless the result is exactly the ingest or assets origin. */ export function ingestTarget(host: string, path: string, search: string): URL | null { const origin = ingestUpstream(host, path); const allowed = new Set([host, ingestUpstream(host, "/static/")]); const target = new URL(origin); target.pathname = path; target.search = search; return allowed.has(target.origin) && target.origin === origin ? target : null; } /** * Same-origin reverse proxy for PostHog (/ingest/*), so the page never talks to a third party directly. Only * the headers PostHog needs are forwarded: no cookies, credentials or client IP, and upstream cookies are * dropped. Answers 404 when analytics is off. */ export async function handleIngestProxy(request: Request, url: URL): Promise { if (url.pathname !== INGEST_PREFIX && !url.pathname.startsWith(`${INGEST_PREFIX}/`)) return null; const config = analyticsConfig(); if (!config) return new Response("Not found\n", { status: 404 }); if (request.method !== "GET" && request.method !== "POST" && request.method !== "HEAD") { return new Response("Method not allowed\n", { status: 405 }); } const length = Number(request.headers.get("content-length") ?? 0); if (length > MAX_BODY_BYTES) return new Response("Payload too large\n", { status: 413 }); // The raw request path too: URL parsing may already have collapsed `..` or decoded what a client sent. const rawPathname = request.url.replace(/^[a-z]+:\/\/[^/]+/i, "").split(/[?#]/)[0] ?? ""; const path = ingestPath(url.pathname); const target = path && ingestPath(rawPathname) ? ingestTarget(config.host, path, url.search) : null; if (!target) return new Response("Bad request\n", { status: 400 }); const headers = new Headers(); for (const name of FORWARDED_REQUEST_HEADERS) { const value = request.headers.get(name); if (value) headers.set(name, value); } // PostHog enables replay only for authorized domains, judged by Origin/Referer. Send just the forge origin, // never the page path, which can name a private repository. if (request.headers.has("origin") || request.headers.has("referer")) { headers.set("origin", url.origin); headers.set("referer", `${url.origin}/`); } const hasBody = request.method === "POST"; const upstream = await fetch(target, { method: request.method, headers, body: hasBody ? request.body : undefined, redirect: "manual", }).catch(() => null); if (!upstream) return new Response("Bad gateway\n", { status: 502 }); const responseHeaders = new Headers(upstream.headers); for (const name of DROPPED_RESPONSE_HEADERS) responseHeaders.delete(name); return new Response(upstream.body, { status: upstream.status, headers: responseHeaders }); }