Skip to content

Commit 0eb882e

Browse files
authored
fix(proxy): preserve non-JSON request bodies (#837)
1 parent f84b6b4 commit 0eb882e

2 files changed

Lines changed: 230 additions & 94 deletions

File tree

packages/script/src/runtime/server/proxy-handler.ts

Lines changed: 71 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
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'
33
import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime'
44
import { matchDomain } from './utils/match-domain'
55
import {
@@ -131,20 +131,29 @@ export default defineEventHandler(async (event) => {
131131
const privacy = globalPrivacy !== undefined ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved
132132
const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware
133133

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)
139134
const originalHeaders = getHeaders(event)
140135
const originalQuery = getQuery(event)
141-
const contentType = originalHeaders['content-type'] || ''
136+
const contentType = originalHeaders['content-type']?.toLowerCase() || ''
142137
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(
144149
originalHeaders['content-encoding']
145150
|| contentType.includes('octet-stream')
146151
|| (compressionParam && COMPRESSION_RE.test(compressionParam)),
147152
)
153+
const shouldTransformBody = isWriteMethod
154+
&& anyPrivacy
155+
&& !hasOpaqueBodyEncoding
156+
&& transformableBodyType !== undefined
148157

149158
// Build target URL with stripped query params
150159
let targetUrl = targetBase + remainingPath
@@ -192,10 +201,9 @@ export default defineEventHandler(async (event) => {
192201
if (SENSITIVE_HEADERS.includes(lowerKey))
193202
continue
194203

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.
197205
if (lowerKey === 'content-length') {
198-
if (anyPrivacy && !isBinaryBody)
206+
if (shouldTransformBody)
199207
continue
200208
headers[lowerKey] = value
201209
continue
@@ -266,100 +274,69 @@ export default defineEventHandler(async (event) => {
266274
}
267275

268276
// 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
270278
let rawBody: unknown
271279
// When true, body is not read — the raw request stream is piped directly to upstream
272280
let passthroughBody = false
273-
const method = event.method?.toUpperCase()
274-
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'PATCH'
275281

276282
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.
279285
passthroughBody = true
280286
}
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]
328299
}
329300
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
358302
}
359303
}
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))
362317
}
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
363340
}
364341
}
365342
}
@@ -401,7 +378,7 @@ export default defineEventHandler(async (event) => {
401378
fetchBody = getRequestWebStream(event) as BodyInit | undefined
402379
}
403380
else if (body !== undefined) {
404-
fetchBody = typeof body === 'string' ? body : JSON.stringify(body)
381+
fetchBody = transformableBodyType === 'json' ? JSON.stringify(body) : String(body)
405382
}
406383

407384
let response: Response
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { Server } from 'node:http'
2+
import { createServer } from 'node:http'
3+
import { gzipSync } from 'node:zlib'
4+
import { createApp, defineEventHandler, readRawBody, toNodeListener } from 'h3'
5+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import proxyHandler from '../../packages/script/src/runtime/server/proxy-handler'
7+
8+
vi.mock('nitropack/runtime', () => ({
9+
useRuntimeConfig: () => ({
10+
'nuxt-scripts-proxy': {
11+
proxyPrefix: '/_scripts/p',
12+
domainPrivacy: {
13+
'upstream.test': true,
14+
},
15+
debug: false,
16+
},
17+
}),
18+
useNitroApp: () => ({
19+
hooks: { callHook: async () => {} },
20+
}),
21+
}))
22+
23+
describe('proxy handler request bodies (#836)', () => {
24+
let upstreamServer: Server
25+
let proxyServer: Server
26+
let upstreamPort: number
27+
let proxyPort: number
28+
let capturedBody = Buffer.alloc(0)
29+
let capturedContentLength: string | undefined
30+
let capturedContentType: string | undefined
31+
const realFetch = globalThis.fetch
32+
33+
beforeAll(async () => {
34+
const upstreamApp = createApp()
35+
upstreamApp.use('/', defineEventHandler(async (event) => {
36+
const rawBody = await readRawBody(event, false)
37+
capturedBody = rawBody ? Buffer.from(rawBody) : Buffer.alloc(0)
38+
capturedContentLength = event.headers.get('content-length') ?? undefined
39+
capturedContentType = event.headers.get('content-type') ?? undefined
40+
return { status: 1 }
41+
}))
42+
43+
upstreamServer = createServer(toNodeListener(upstreamApp))
44+
await new Promise<void>(resolve => upstreamServer.listen(0, resolve))
45+
upstreamPort = (upstreamServer.address() as { port: number }).port
46+
47+
globalThis.fetch = (input, init) => {
48+
const requestUrl = input instanceof Request ? input.url : String(input)
49+
const url = new URL(requestUrl)
50+
if (url.hostname === 'upstream.test') {
51+
const redirected = `http://127.0.0.1:${upstreamPort}${url.pathname}${url.search}`
52+
return realFetch(redirected, init)
53+
}
54+
return realFetch(input, init)
55+
}
56+
57+
const proxyApp = createApp()
58+
proxyApp.use(proxyHandler)
59+
proxyServer = createServer(toNodeListener(proxyApp))
60+
await new Promise<void>(resolve => proxyServer.listen(0, resolve))
61+
proxyPort = (proxyServer.address() as { port: number }).port
62+
})
63+
64+
beforeEach(() => {
65+
capturedBody = Buffer.alloc(0)
66+
capturedContentLength = undefined
67+
capturedContentType = undefined
68+
})
69+
70+
afterAll(async () => {
71+
globalThis.fetch = realFetch
72+
await Promise.all([
73+
new Promise<void>(resolve => upstreamServer.close(() => resolve())),
74+
new Promise<void>(resolve => proxyServer.close(() => resolve())),
75+
])
76+
})
77+
78+
it('preserves an opaque gzip body without a compression query parameter', async () => {
79+
const compressed = gzipSync(JSON.stringify({ event: '$pageview' }))
80+
81+
const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/i/v0/e/`, {
82+
method: 'POST',
83+
headers: { 'content-type': 'text/plain' },
84+
body: compressed,
85+
})
86+
87+
expect(response.status).toBe(200)
88+
expect(capturedBody.equals(compressed)).toBe(true)
89+
expect(capturedContentType).toBe('text/plain')
90+
})
91+
92+
it('preserves form encoding when privacy transforms are active', async () => {
93+
const formBody = 'tag=a&event=%24pageview&tag=b&hardwareConcurrency=128'
94+
const transformedBody = 'tag=a&event=%24pageview&tag=b&hardwareConcurrency=16'
95+
96+
const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/i/v0/e/`, {
97+
method: 'POST',
98+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
99+
body: formBody,
100+
})
101+
102+
expect(response.status).toBe(200)
103+
expect(capturedBody.toString()).toBe(transformedBody)
104+
expect(capturedContentLength).toBe(String(Buffer.byteLength(transformedBody)))
105+
expect(capturedContentLength).not.toBe(String(Buffer.byteLength(formBody)))
106+
expect(capturedContentType).toBe('application/x-www-form-urlencoded')
107+
})
108+
109+
it('applies privacy transforms to JSON objects and arrays', async () => {
110+
const bodies = [
111+
{
112+
input: { event: '$pageview', sr: '2560x1440' },
113+
expected: { event: '$pageview', sr: '1920x1080' },
114+
},
115+
{
116+
input: [{ hardwareConcurrency: 128 }, 'unchanged'],
117+
expected: [{ hardwareConcurrency: 16 }, 'unchanged'],
118+
},
119+
]
120+
121+
for (const { input, expected } of bodies) {
122+
const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/i/v0/e/`, {
123+
method: 'POST',
124+
headers: { 'content-type': 'application/json' },
125+
body: JSON.stringify(input),
126+
})
127+
128+
expect(response.status).toBe(200)
129+
expect(JSON.parse(capturedBody.toString())).toEqual(expected)
130+
}
131+
})
132+
133+
it.each([
134+
['string', '"value"'],
135+
['number', '42'],
136+
['boolean', 'true'],
137+
['null', 'null'],
138+
])('preserves JSON %s primitives', async (_name, jsonBody) => {
139+
const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/i/v0/e/`, {
140+
method: 'POST',
141+
headers: { 'content-type': 'application/json' },
142+
body: jsonBody,
143+
})
144+
145+
expect(response.status).toBe(200)
146+
expect(capturedBody.toString()).toBe(jsonBody)
147+
})
148+
149+
it('rejects malformed JSON before forwarding upstream', async () => {
150+
const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/i/v0/e/`, {
151+
method: 'POST',
152+
headers: { 'content-type': 'application/json' },
153+
body: '{"event":',
154+
})
155+
156+
expect(response.status).toBe(400)
157+
expect(capturedBody).toHaveLength(0)
158+
})
159+
})

0 commit comments

Comments
 (0)