-
Notifications
You must be signed in to change notification settings - Fork 81
fix: preserve compressed/binary request bodies in proxy handler #619
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
676a217
556b6ed
3b7e564
c97fc8d
b0a9857
75639f2
6b53d3d
877439b
05f26ca
f96cdaa
c98e590
bf58233
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { defineEventHandler, getHeaders, getRequestIP, readBody, getQuery, setResponseHeader, createError } from 'h3' | ||
| import { defineEventHandler, getHeaders, getRequestIP, readBody, getRequestWebStream, getQuery, setResponseHeader, createError } from 'h3' | ||
| import { useRuntimeConfig } from '#imports' | ||
| import { useNitroApp } from 'nitropack/runtime' | ||
| import { | ||
|
|
@@ -102,6 +102,20 @@ export default defineEventHandler(async (event) => { | |
| const privacy = globalPrivacy !== undefined ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved | ||
| const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware | ||
|
|
||
| // Detect binary/compressed bodies that cannot be safely parsed as text. | ||
| // These must be passed through as raw bytes to avoid corruption: | ||
| // - content-encoding: transport-level compression (gzip, br, etc.) | ||
| // - application/octet-stream: explicitly binary content | ||
| // - ?compression=gzip-js: client-side compression (e.g. PostHog sends gzip bytes as text/plain) | ||
| const originalHeaders = getHeaders(event) | ||
| const contentType = originalHeaders['content-type'] || '' | ||
| const compressionParam = new URL(event.path, 'http://localhost').searchParams.get('compression') | ||
| const isBinaryBody = Boolean( | ||
| originalHeaders['content-encoding'] | ||
| || contentType.includes('octet-stream') | ||
| || (compressionParam && /gzip|deflate|br|compress|base64/i.test(compressionParam)), | ||
| ) | ||
|
Comment on lines
+111
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normalize At Line 115 and Line 257, π‘ Suggested fix- const contentType = originalHeaders['content-type'] || ''
+ const contentType = originalHeaders['content-type'] || ''
+ const normalizedContentType = contentType.toLowerCase()
@@
- || contentType.includes('octet-stream')
+ || normalizedContentType.includes('octet-stream')
@@
- else if (contentType.includes('application/x-www-form-urlencoded')) {
+ else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {Also applies to: 257-257 π€ Prompt for AI Agents |
||
|
|
||
| // Build target URL with stripped query params | ||
| let targetPath = path.slice(matchedPrefix.length) | ||
| // Ensure path starts with / | ||
|
|
@@ -121,8 +135,6 @@ export default defineEventHandler(async (event) => { | |
| } | ||
| } | ||
|
|
||
| // Get original headers | ||
| const originalHeaders = getHeaders(event) | ||
| const headers: Record<string, string> = {} | ||
|
|
||
| // Process headers based on per-flag privacy | ||
|
|
@@ -133,8 +145,13 @@ export default defineEventHandler(async (event) => { | |
| // SENSITIVE_HEADERS always stripped regardless of privacy flags | ||
| if (SENSITIVE_HEADERS.includes(lowerKey)) continue | ||
|
|
||
| // Skip content-length when any privacy is active β body may be modified | ||
| if (anyPrivacy && lowerKey === 'content-length') continue | ||
| // Skip content-length when body will be modified by privacy transforms | ||
| // (preserved for binary passthrough and no-privacy paths) | ||
| if (lowerKey === 'content-length') { | ||
| if (anyPrivacy && !isBinaryBody) continue | ||
| headers[lowerKey] = value | ||
| continue | ||
| } | ||
|
|
||
| // IP-revealing headers β controlled by ip flag | ||
| if (lowerKey === 'x-forwarded-for' || lowerKey === 'x-real-ip' || lowerKey === 'forwarded' | ||
|
|
@@ -198,64 +215,85 @@ export default defineEventHandler(async (event) => { | |
| .join(', ') | ||
| } | ||
|
|
||
| // Read and process request body if present | ||
| let body: string | Record<string, unknown> | undefined | ||
| // Process request body: either stream through raw or read + transform | ||
| let body: string | Record<string, unknown> | unknown[] | undefined | ||
| let rawBody: unknown | ||
| const contentType = originalHeaders['content-type'] || '' | ||
| // When true, body is not read β the raw request stream is piped directly to upstream | ||
| let passthroughBody = false | ||
| const method = event.method?.toUpperCase() | ||
| const originalQuery = getQuery(event) | ||
| const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'PATCH' | ||
|
|
||
| if (method === 'POST' || method === 'PUT' || method === 'PATCH') { | ||
| rawBody = await readBody(event) | ||
|
|
||
| if (anyPrivacy && rawBody) { | ||
| if (typeof rawBody === 'object') { | ||
| // JSON body - strip fingerprinting recursively | ||
| body = stripPayloadFingerprinting(rawBody as Record<string, unknown>, privacy) | ||
| } | ||
| else if (typeof rawBody === 'string') { | ||
| // Try parsing as JSON first (sendBeacon often sends JSON with text/plain content-type) | ||
| if (rawBody.startsWith('{') || rawBody.startsWith('[')) { | ||
| let parsed: unknown = null | ||
| try { | ||
| parsed = JSON.parse(rawBody) | ||
| if (isWriteMethod) { | ||
| if (isBinaryBody || !anyPrivacy) { | ||
| // No transforms needed β don't read the body at all, stream it through directly. | ||
| passthroughBody = true | ||
| } | ||
| else { | ||
| // Text body with privacy transforms β parse and strip fingerprinting | ||
| rawBody = await readBody(event) | ||
|
|
||
| if (rawBody != null) { | ||
| if (Array.isArray(rawBody)) { | ||
| // JSON array body (e.g. batch payloads) β strip each element individually | ||
| body = rawBody.map(item => | ||
| item && typeof item === 'object' && !Array.isArray(item) | ||
| ? stripPayloadFingerprinting(item as Record<string, unknown>, privacy) | ||
| : item, | ||
| ) | ||
| } | ||
| else if (typeof rawBody === 'object') { | ||
| // JSON object body - strip fingerprinting recursively | ||
| body = stripPayloadFingerprinting(rawBody as Record<string, unknown>, privacy) | ||
| } | ||
| else if (typeof rawBody === 'string') { | ||
| // Try parsing as JSON first (sendBeacon often sends JSON with text/plain content-type) | ||
| if (rawBody.startsWith('{') || rawBody.startsWith('[')) { | ||
| let parsed: unknown = null | ||
| try { | ||
| parsed = JSON.parse(rawBody) | ||
| } | ||
| catch { /* not valid JSON */ } | ||
|
|
||
| if (Array.isArray(parsed)) { | ||
| body = parsed.map(item => | ||
| item && typeof item === 'object' && !Array.isArray(item) | ||
| ? stripPayloadFingerprinting(item as Record<string, unknown>, privacy) | ||
| : item, | ||
| ) | ||
| } | ||
| else if (parsed && typeof parsed === 'object') { | ||
| body = stripPayloadFingerprinting(parsed as Record<string, unknown>, privacy) | ||
| } | ||
| else { | ||
| body = rawBody | ||
| } | ||
| } | ||
| catch { /* not valid JSON */ } | ||
|
|
||
| if (parsed && typeof parsed === 'object') { | ||
| body = stripPayloadFingerprinting(parsed as Record<string, unknown>, privacy) | ||
| else if (contentType.includes('application/x-www-form-urlencoded')) { | ||
| // URL-encoded form data | ||
| const params = new URLSearchParams(rawBody) | ||
| const obj: Record<string, unknown> = {} | ||
| params.forEach((value, key) => { | ||
| obj[key] = value | ||
| }) | ||
| const stripped = stripPayloadFingerprinting(obj, privacy) | ||
| // Convert all values to strings β URLSearchParams coerces non-strings | ||
| // to "[object Object]" which corrupts nested objects/arrays | ||
| const stringified: Record<string, string> = {} | ||
| for (const [k, v] of Object.entries(stripped)) { | ||
| if (v === undefined || v === null) continue | ||
| stringified[k] = typeof v === 'string' ? v : JSON.stringify(v) | ||
| } | ||
| body = new URLSearchParams(stringified).toString() | ||
| } | ||
| else { | ||
| body = rawBody | ||
| } | ||
| } | ||
| else if (contentType.includes('application/x-www-form-urlencoded')) { | ||
| // URL-encoded form data | ||
| const params = new URLSearchParams(rawBody) | ||
| const obj: Record<string, unknown> = {} | ||
| params.forEach((value, key) => { | ||
| obj[key] = value | ||
| }) | ||
| const stripped = stripPayloadFingerprinting(obj, privacy) | ||
| // Convert all values to strings β URLSearchParams coerces non-strings | ||
| // to "[object Object]" which corrupts nested objects/arrays | ||
| const stringified: Record<string, string> = {} | ||
| for (const [k, v] of Object.entries(stripped)) { | ||
| if (v === undefined || v === null) continue | ||
| stringified[k] = typeof v === 'string' ? v : JSON.stringify(v) | ||
| } | ||
| body = new URLSearchParams(stringified).toString() | ||
| } | ||
| else { | ||
| body = rawBody | ||
| body = rawBody as string | ||
| } | ||
| } | ||
| else { | ||
| body = rawBody as string | ||
| } | ||
| } | ||
| else { | ||
| body = rawBody as string | Record<string, unknown> | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -266,15 +304,16 @@ export default defineEventHandler(async (event) => { | |
| targetUrl, | ||
| method: method || 'GET', | ||
| privacy, | ||
| passthroughBody, | ||
| original: { | ||
| headers: { ...originalHeaders }, | ||
| query: originalQuery, | ||
| body: rawBody ?? null, | ||
| body: passthroughBody ? '<passthrough>' : (rawBody ?? null), | ||
| }, | ||
| stripped: { | ||
| headers, | ||
| query: anyPrivacy ? stripPayloadFingerprinting(originalQuery, privacy) : originalQuery, | ||
| body: body ?? null, | ||
| body: passthroughBody ? '<passthrough>' : (body ?? null), | ||
| }, | ||
| }) | ||
|
|
||
|
|
@@ -284,14 +323,25 @@ export default defineEventHandler(async (event) => { | |
| const controller = new AbortController() | ||
| const timeoutId = setTimeout(() => controller.abort(), 15000) // 15s timeout | ||
|
|
||
| // Resolve the fetch body: passthrough streams the raw request, otherwise serialize | ||
| let fetchBody: BodyInit | undefined | ||
| if (passthroughBody) { | ||
| fetchBody = getRequestWebStream(event) as BodyInit | undefined | ||
| } | ||
| else if (body !== undefined) { | ||
| fetchBody = typeof body === 'string' ? body : JSON.stringify(body) | ||
| } | ||
|
|
||
| let response: Response | ||
| try { | ||
| response = await fetch(targetUrl, { | ||
| method: method || 'GET', | ||
| headers, | ||
| body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, | ||
| body: fetchBody, | ||
| credentials: 'omit', // Don't send cookies to third parties | ||
| signal: controller.signal, | ||
| // @ts-expect-error Node fetch supports duplex for streaming request bodies | ||
| duplex: passthroughBody ? 'half' : undefined, | ||
| }) | ||
| } | ||
| catch (err: unknown) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| [ | ||
| { | ||
| "method": "GET", | ||
| "original": { | ||
| "body": null, | ||
| "query": {}, | ||
| }, | ||
| "path": "/_proxy/clarity-scripts/0.8.56/clarity.js", | ||
| "privacy": { | ||
| "hardware": true, | ||
| "ip": true, | ||
| "language": true, | ||
| "screen": false, | ||
| "timezone": false, | ||
| "userAgent": false, | ||
| }, | ||
| "stripped": { | ||
| "body": null, | ||
| "query": {}, | ||
| }, | ||
| "targetUrl": "https://scripts.clarity.ms/0.8.56/clarity.js", | ||
| }, | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "headers": { | ||
| "x-forwarded-for": { | ||
| "anonymized": "127.0.0.0", | ||
| "original": "<absent>", | ||
| }, | ||
| }, | ||
| "method": "GET", | ||
| "target": "scripts.clarity.ms/0.8.56/clarity.js", | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.