11/**
2- * Reverse-proxy: /ingest/** → eu.i.posthog.com/**
2+ * Reverse-proxy: /ingest/** → eu.i.posthog.com/** (and /ingest/static/** →
3+ * eu-assets.i.posthog.com/**).
34 *
4- * Proxies PostHog API calls (event capture, decide, feature flags) through
5- * our domain to bypass ad-blockers. Uses an explicit server route instead
6- * of Nitro routeRules for better error handling and compression compatibility.
5+ * Proxies PostHog API calls (event capture, decide, feature flags) and the
6+ * autocapture/web-vitals static assets through our domain to bypass
7+ * ad-blockers.
8+ *
9+ * IMPORTANT — why we do NOT use h3's `proxyRequest` here:
10+ *
11+ * app.reqcore.com is behind Cloudflare (in front of Railway). Inbound
12+ * requests therefore arrive carrying CF-* headers (cf-connecting-ip,
13+ * cf-ray, cf-ipcountry, cf-visitor) plus an X-Forwarded-For chain that
14+ * starts with a Cloudflare edge IP. `proxyRequest` forwards ALL inbound
15+ * headers verbatim, so PostHog's Cloudflare sees a request that looks
16+ * like it came from another Cloudflare-protected site and rejects it
17+ * with HTTP 403 + Error 1000 ("DNS points to prohibited IP") and an
18+ * HTML body. The browser then refuses to execute that HTML as JS due
19+ * to our X-Content-Type-Options: nosniff header — surfacing as
20+ * NS_ERROR_CORRUPTED_CONTENT / "MIME type mismatch" in the console.
21+ *
22+ * Doing a manual `fetch` with an explicit, minimal allow-list of
23+ * outbound headers sidesteps the problem entirely: Cloudflare sees a
24+ * normal direct request and serves the asset.
725 */
26+
27+ // Methods that do not carry a request body (per RFC 9110). fetch() throws
28+ // if you pass a body with these methods.
29+ const BODYLESS_METHODS = new Set ( [ 'GET' , 'HEAD' ] )
30+
31+ // Headers we are willing to forward upstream. Anything not on this list
32+ // (Cookie, Authorization, all CF-*, X-Forwarded-*, X-Real-IP, Host, etc.)
33+ // is dropped so PostHog's CDN does not see anything that looks like a
34+ // proxy/Cloudflare loop.
35+ const FORWARDABLE_REQUEST_HEADERS = new Set ( [
36+ 'accept' ,
37+ 'accept-encoding' ,
38+ 'accept-language' ,
39+ 'content-type' ,
40+ 'content-length' ,
41+ 'origin' ,
42+ 'referer' ,
43+ 'user-agent' ,
44+ ] )
45+
46+ // Response headers we strip before relaying back to the browser. Hop-by-hop
47+ // headers per RFC 7230 §6.1, plus a couple that would conflict with our own
48+ // security headers if they leaked through.
49+ const STRIPPED_RESPONSE_HEADERS = new Set ( [
50+ 'connection' ,
51+ 'keep-alive' ,
52+ 'proxy-authenticate' ,
53+ 'proxy-authorization' ,
54+ 'te' ,
55+ 'trailer' ,
56+ 'transfer-encoding' ,
57+ 'upgrade' ,
58+ // Let Nitro handle compression negotiation; passing through an upstream
59+ // content-encoding alongside a decoded body would corrupt the response.
60+ 'content-encoding' ,
61+ 'content-length' ,
62+ ] )
63+
864export default defineEventHandler ( async ( event ) => {
965 const path = getRouterParam ( event , 'path' ) || ''
1066 const query = getQuery ( event )
@@ -13,29 +69,51 @@ export default defineEventHandler(async (event) => {
1369 // Static assets (autocapture scripts, web-vitals, etc.) live on the
1470 // assets host. Everything else (capture, decide, flags) goes to the
1571 // main ingestion host.
16- const host = path . startsWith ( 'static/' )
72+ const upstreamOrigin = path . startsWith ( 'static/' )
1773 ? 'https://eu-assets.i.posthog.com'
1874 : 'https://eu.i.posthog.com'
19- const target = `${ host } /${ path } ${ qs ? `?${ qs } ` : '' } `
20- const targetHost = new URL ( target ) . host
21-
22- // h3's proxyRequest forwards the inbound Host header by default. PostHog's
23- // CDN (Cloudflare) rejects requests whose Host header does not match the
24- // upstream domain with HTTP 403 + "DNS points to prohibited IP" (Cloudflare
25- // error 1000) and an HTML body — which the browser then refuses to execute
26- // as JavaScript due to the MIME-type mismatch (X-Content-Type-Options:
27- // nosniff). We must rewrite Host (and the related forwarding headers) so
28- // the upstream sees a request that looks like it was made directly to it.
29- return proxyRequest ( event , target , {
30- headers : {
31- host : targetHost ,
32- 'x-forwarded-host' : targetHost ,
33- 'x-forwarded-for' : getRequestIP ( event ) || '' ,
34- 'x-forwarded-proto' : 'https' ,
35- // Drop cookies — they belong to app.reqcore.com and have no meaning at
36- // PostHog. Forwarding them risks leaking session tokens to a third
37- // party and bloats the request.
38- cookie : '' ,
39- } ,
75+ const target = `${ upstreamOrigin } /${ path } ${ qs ? `?${ qs } ` : '' } `
76+
77+ const inboundHeaders = getRequestHeaders ( event )
78+ const outboundHeaders : Record < string , string > = { }
79+ for ( const [ name , value ] of Object . entries ( inboundHeaders ) ) {
80+ if ( ! value ) continue
81+ if ( FORWARDABLE_REQUEST_HEADERS . has ( name . toLowerCase ( ) ) ) {
82+ outboundHeaders [ name ] = Array . isArray ( value ) ? value . join ( ', ' ) : value
83+ }
84+ }
85+
86+ const method = event . method . toUpperCase ( )
87+ const body = BODYLESS_METHODS . has ( method )
88+ ? undefined
89+ : await readRawBody ( event , false ) // returns Buffer | undefined
90+
91+ let upstream : Response
92+ try {
93+ upstream = await fetch ( target , {
94+ method,
95+ headers : outboundHeaders ,
96+ body : body as BodyInit | undefined ,
97+ redirect : 'manual' ,
98+ } )
99+ } catch ( err ) {
100+ throw createError ( {
101+ statusCode : 502 ,
102+ statusMessage : 'Bad Gateway' ,
103+ message : `PostHog proxy upstream error: ${ ( err as Error ) . message } ` ,
104+ } )
105+ }
106+
107+ // Mirror upstream status & safe headers
108+ setResponseStatus ( event , upstream . status , upstream . statusText )
109+ upstream . headers . forEach ( ( value , name ) => {
110+ if ( ! STRIPPED_RESPONSE_HEADERS . has ( name . toLowerCase ( ) ) ) {
111+ setResponseHeader ( event , name , value )
112+ }
40113 } )
114+
115+ // Stream the body back. Buffer is fine here — PostHog static assets are
116+ // small (<200 KB) and capture endpoints return tiny JSON payloads.
117+ const buf = Buffer . from ( await upstream . arrayBuffer ( ) )
118+ return buf
41119} )
0 commit comments