Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## v1.0.0-beta.5...main

[compare changes](https://github.com/nuxt/scripts/compare/v1.0.0-beta.5...main)

### πŸ’… Refactors

- Replace SW + beacon monkey-patch with AST-based API rewriting ([#614](https://github.com/nuxt/scripts/pull/614))

### 🏑 Chore

- Bump deps ([814bbf6](https://github.com/nuxt/scripts/commit/814bbf6))

### ❀️ Contributors

- Harlan Wilton ([@harlan-zw](https://github.com/harlan-zw))

## v1.0.0-beta.4...main

[compare changes](https://github.com/nuxt/scripts/compare/v1.0.0-beta.4...main)
Expand Down
7 changes: 4 additions & 3 deletions src/plugins/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts'):
if (hasProtocol(src, { acceptRelative: true })) {
src = src.replace(/^\/\//, 'https://')
const url = parseURL(src)
const file = [
`${ohash(url)}.js`, // force an extension
].filter(Boolean).join('-')
const h = ohash(url)
// Prefix hashes starting with '-' β€” Nitro's publicAssets handler cannot serve
// files whose names begin with a dash (they get omitted from the asset manifest).
const file = `${h.startsWith('-') ? `_${h.slice(1)}` : h}.js`
const nuxt = tryUseNuxt()
// Use cdnURL if available, otherwise fall back to baseURL
const cdnURL = nuxt?.options.runtimeConfig?.app?.cdnURL || nuxt?.options.app?.cdnURL || ''
Expand Down
2 changes: 2 additions & 0 deletions src/proxy-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,11 @@ function buildProxyConfig(collectPrefix: string) {
privacy: { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true },
rewrite: [
{ from: 'alb.reddit.com', to: `${collectPrefix}/reddit` },
{ from: 'pixel-config.reddit.com', to: `${collectPrefix}/reddit-cfg` },
],
routes: {
[`${collectPrefix}/reddit/**`]: { proxy: 'https://alb.reddit.com/**' },
[`${collectPrefix}/reddit-cfg/**`]: { proxy: 'https://pixel-config.reddit.com/**' },
},
},

Expand Down
156 changes: 103 additions & 53 deletions src/runtime/server/proxy-handler.ts
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 {
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Normalize Content-Type before matching.

At Line 115 and Line 257, includes(...) checks are case-sensitive. Content-Type is case-insensitive by spec, so mixed-case values can bypass binary/form handling and route through the wrong path.

πŸ’‘ 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
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/server/proxy-handler.ts` around lines 111 - 117, Normalize the
Content-Type header before doing case-sensitive substring checks: change how
contentType is derived from originalHeaders (e.g., const contentType =
(originalHeaders['content-type'] || '').toLowerCase()) and then use that
lowercased value in isBinaryBody and any other checks (such as the later
includes check around line 257) so matches like 'Octet-Stream' or mixed-case
types are handled correctly; update all occurrences where
contentType.includes(...) is used to reference the normalized variable.


// Build target URL with stripped query params
let targetPath = path.slice(matchedPrefix.length)
// Ensure path starts with /
Expand All @@ -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
Expand All @@ -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'
Expand Down Expand Up @@ -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>
}
}

Expand All @@ -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),
},
})

Expand All @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions test/e2e/__snapshots__/proxy/clarity.json
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",
}
Loading
Loading