|
1 | 1 | import type { ProxyPrivacyInput, ResolvedProxyPrivacy } from './utils/privacy' |
2 | | -import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, setResponseHeader, setResponseStatus } from 'h3' |
| 2 | +import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, readRawBody, setResponseHeader, setResponseStatus } from 'h3' |
3 | 3 | import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime' |
4 | 4 | import { matchDomain } from './utils/match-domain' |
5 | 5 | import { |
@@ -131,20 +131,29 @@ export default defineEventHandler(async (event) => { |
131 | 131 | const privacy = globalPrivacy !== undefined ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved |
132 | 132 | const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware |
133 | 133 |
|
134 | | - // Detect binary/compressed bodies that cannot be safely parsed as text. |
135 | | - // These must be passed through as raw bytes to avoid corruption: |
136 | | - // - content-encoding: transport-level compression (gzip, br, etc.) |
137 | | - // - application/octet-stream: explicitly binary content |
138 | | - // - ?compression=gzip-js: client-side compression (e.g. PostHog sends gzip bytes as text/plain) |
139 | 134 | const originalHeaders = getHeaders(event) |
140 | 135 | const originalQuery = getQuery(event) |
141 | | - const contentType = originalHeaders['content-type'] || '' |
| 136 | + const contentType = originalHeaders['content-type']?.toLowerCase() || '' |
142 | 137 | const compressionParam = (originalQuery.compression as string) || '' |
143 | | - const isBinaryBody = Boolean( |
| 138 | + const method = event.method?.toUpperCase() |
| 139 | + const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'PATCH' |
| 140 | + const transformableBodyType = contentType.includes('application/x-www-form-urlencoded') |
| 141 | + ? 'form' |
| 142 | + : contentType.includes('json') |
| 143 | + ? 'json' |
| 144 | + : undefined |
| 145 | + |
| 146 | + // Only parse formats whose structure is known. Opaque bodies may contain binary |
| 147 | + // data despite a text content type, as with PostHog's gzip payloads. |
| 148 | + const hasOpaqueBodyEncoding = Boolean( |
144 | 149 | originalHeaders['content-encoding'] |
145 | 150 | || contentType.includes('octet-stream') |
146 | 151 | || (compressionParam && COMPRESSION_RE.test(compressionParam)), |
147 | 152 | ) |
| 153 | + const shouldTransformBody = isWriteMethod |
| 154 | + && anyPrivacy |
| 155 | + && !hasOpaqueBodyEncoding |
| 156 | + && transformableBodyType !== undefined |
148 | 157 |
|
149 | 158 | // Build target URL with stripped query params |
150 | 159 | let targetUrl = targetBase + remainingPath |
@@ -192,10 +201,9 @@ export default defineEventHandler(async (event) => { |
192 | 201 | if (SENSITIVE_HEADERS.includes(lowerKey)) |
193 | 202 | continue |
194 | 203 |
|
195 | | - // Skip content-length when body will be modified by privacy transforms |
196 | | - // (preserved for binary passthrough and no-privacy paths) |
| 204 | + // Skip content-length when body will be modified by privacy transforms. |
197 | 205 | if (lowerKey === 'content-length') { |
198 | | - if (anyPrivacy && !isBinaryBody) |
| 206 | + if (shouldTransformBody) |
199 | 207 | continue |
200 | 208 | headers[lowerKey] = value |
201 | 209 | continue |
@@ -266,100 +274,69 @@ export default defineEventHandler(async (event) => { |
266 | 274 | } |
267 | 275 |
|
268 | 276 | // Process request body: either stream through raw or read + transform |
269 | | - let body: string | Record<string, unknown> | unknown[] | undefined |
| 277 | + let body: string | Record<string, unknown> | unknown[] | number | boolean | null | undefined |
270 | 278 | let rawBody: unknown |
271 | 279 | // When true, body is not read — the raw request stream is piped directly to upstream |
272 | 280 | let passthroughBody = false |
273 | | - const method = event.method?.toUpperCase() |
274 | | - const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'PATCH' |
275 | 281 |
|
276 | 282 | if (isWriteMethod) { |
277 | | - if (isBinaryBody || !anyPrivacy) { |
278 | | - // No transforms needed — don't read the body at all, stream it through directly. |
| 283 | + if (!shouldTransformBody) { |
| 284 | + // No safe transforms available or needed. Stream the original bytes directly. |
279 | 285 | passthroughBody = true |
280 | 286 | } |
281 | | - else { |
282 | | - // Text body with privacy transforms — parse and strip fingerprinting |
283 | | - rawBody = await readBody(event) |
284 | | - |
285 | | - if (rawBody != null) { |
286 | | - if (Array.isArray(rawBody)) { |
287 | | - // JSON array body (e.g. batch payloads) — strip each element individually |
288 | | - body = rawBody.map(item => |
289 | | - item && typeof item === 'object' && !Array.isArray(item) |
290 | | - ? stripPayloadFingerprinting(item as Record<string, unknown>, privacy) |
291 | | - : item, |
292 | | - ) |
293 | | - } |
294 | | - else if (typeof rawBody === 'object') { |
295 | | - // JSON object body - strip fingerprinting recursively |
296 | | - body = stripPayloadFingerprinting(rawBody as Record<string, unknown>, privacy) |
297 | | - } |
298 | | - else if (typeof rawBody === 'string') { |
299 | | - if (contentType.includes('application/x-www-form-urlencoded')) { |
300 | | - // URL-encoded form data — preserve repeated keys (e.g. ?tag=a&tag=b) |
301 | | - const params = new URLSearchParams(rawBody) |
302 | | - const obj: Record<string, unknown> = {} |
303 | | - for (const [key, value] of params.entries()) { |
304 | | - if (key in obj) { |
305 | | - // Repeated key → accumulate as array |
306 | | - const existing = obj[key] |
307 | | - obj[key] = Array.isArray(existing) ? [...existing, value] : [existing, value] |
308 | | - } |
309 | | - else { |
310 | | - obj[key] = value |
311 | | - } |
312 | | - } |
313 | | - const stripped = stripPayloadFingerprinting(obj, privacy) |
314 | | - // Reconstruct form data, expanding arrays back to repeated keys |
315 | | - const out = new URLSearchParams() |
316 | | - for (const [k, v] of Object.entries(stripped)) { |
317 | | - if (v === undefined || v === null) |
318 | | - continue |
319 | | - if (Array.isArray(v)) { |
320 | | - for (const item of v) |
321 | | - out.append(k, typeof item === 'string' ? item : JSON.stringify(item)) |
322 | | - } |
323 | | - else { |
324 | | - out.append(k, typeof v === 'string' ? v : JSON.stringify(v)) |
325 | | - } |
326 | | - } |
327 | | - body = out.toString() |
| 287 | + else if (transformableBodyType === 'form') { |
| 288 | + const formBody = await readRawBody(event) |
| 289 | + rawBody = formBody |
| 290 | + |
| 291 | + if (formBody != null) { |
| 292 | + // Preserve repeated keys while applying privacy transforms to form fields. |
| 293 | + const params = new URLSearchParams(formBody) |
| 294 | + const formRecord: Record<string, unknown> = Object.create(null) |
| 295 | + for (const [key, value] of params.entries()) { |
| 296 | + if (Object.hasOwn(formRecord, key)) { |
| 297 | + const existing = formRecord[key] |
| 298 | + formRecord[key] = Array.isArray(existing) ? [...existing, value] : [existing, value] |
328 | 299 | } |
329 | 300 | else { |
330 | | - // Try parsing as JSON: explicit JSON content-type, or heuristic for |
331 | | - // sendBeacon payloads that send JSON with text/plain content-type |
332 | | - const maybeJson = contentType.includes('json') |
333 | | - || (rawBody.startsWith('{') || rawBody.startsWith('[')) |
334 | | - if (maybeJson) { |
335 | | - let parsed: unknown = null |
336 | | - try { |
337 | | - parsed = JSON.parse(rawBody) |
338 | | - } |
339 | | - catch { /* not valid JSON — fall through to raw */ } |
340 | | - |
341 | | - if (Array.isArray(parsed)) { |
342 | | - body = parsed.map(item => |
343 | | - item && typeof item === 'object' && !Array.isArray(item) |
344 | | - ? stripPayloadFingerprinting(item as Record<string, unknown>, privacy) |
345 | | - : item, |
346 | | - ) |
347 | | - } |
348 | | - else if (parsed && typeof parsed === 'object') { |
349 | | - body = stripPayloadFingerprinting(parsed as Record<string, unknown>, privacy) |
350 | | - } |
351 | | - else { |
352 | | - body = rawBody |
353 | | - } |
354 | | - } |
355 | | - else { |
356 | | - body = rawBody |
357 | | - } |
| 301 | + formRecord[key] = value |
358 | 302 | } |
359 | 303 | } |
360 | | - else { |
361 | | - body = rawBody as string |
| 304 | + |
| 305 | + const stripped = stripPayloadFingerprinting(formRecord, privacy) |
| 306 | + const transformedValues = new Map<string, unknown[]>() |
| 307 | + for (const [key, value] of Object.entries(stripped)) { |
| 308 | + transformedValues.set(key, Array.isArray(value) ? [...value] : [value]) |
| 309 | + } |
| 310 | + |
| 311 | + const transformed = new URLSearchParams() |
| 312 | + for (const [key] of params.entries()) { |
| 313 | + const value = transformedValues.get(key)?.shift() |
| 314 | + if (value === undefined || value === null) |
| 315 | + continue |
| 316 | + transformed.append(key, typeof value === 'string' ? value : JSON.stringify(value)) |
362 | 317 | } |
| 318 | + body = transformed.toString() |
| 319 | + } |
| 320 | + } |
| 321 | + else { |
| 322 | + // JSON body with privacy transforms. |
| 323 | + rawBody = await readBody(event) |
| 324 | + |
| 325 | + if (Array.isArray(rawBody)) { |
| 326 | + // JSON array body (e.g. batch payloads) — strip each element individually |
| 327 | + body = rawBody.map(item => |
| 328 | + item && typeof item === 'object' && !Array.isArray(item) |
| 329 | + ? stripPayloadFingerprinting(item as Record<string, unknown>, privacy) |
| 330 | + : item, |
| 331 | + ) |
| 332 | + } |
| 333 | + else if (rawBody !== null && typeof rawBody === 'object') { |
| 334 | + // JSON object body, strip fingerprinting recursively. |
| 335 | + body = stripPayloadFingerprinting(rawBody as Record<string, unknown>, privacy) |
| 336 | + } |
| 337 | + else { |
| 338 | + // JSON primitives do not contain fingerprinting fields, but must retain JSON encoding. |
| 339 | + body = rawBody as string | number | boolean | null | undefined |
363 | 340 | } |
364 | 341 | } |
365 | 342 | } |
@@ -401,7 +378,7 @@ export default defineEventHandler(async (event) => { |
401 | 378 | fetchBody = getRequestWebStream(event) as BodyInit | undefined |
402 | 379 | } |
403 | 380 | else if (body !== undefined) { |
404 | | - fetchBody = typeof body === 'string' ? body : JSON.stringify(body) |
| 381 | + fetchBody = transformableBodyType === 'json' ? JSON.stringify(body) : String(body) |
405 | 382 | } |
406 | 383 |
|
407 | 384 | let response: Response |
|
0 commit comments