Skip to content

Commit 8b9ea20

Browse files
JoachimLKCopilot
andcommitted
feat: Enhance PostHog proxy handling with explicit header management and error handling
Co-authored-by: Copilot <copilot@github.com>
1 parent fee0be6 commit 8b9ea20

1 file changed

Lines changed: 104 additions & 26 deletions

File tree

server/routes/ingest/[...path].ts

Lines changed: 104 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,66 @@
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+
864
export 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

Comments
 (0)