Skip to content

Server-side prototype pollution via content block bindings keys in Builder SDKs #4823

Description

@geo-chen

reported via email on 5 July 2026:

We found a prototype-pollution issue in the Gen2 Builder SDKs (packages/sdks, shared by
@builder.io/sdk-react, sdk-vue, sdk-svelte, sdk-qwik, sdk-solid, sdk-react-nextjs, sdk-angular).

packages/sdks/src/functions/get-processed-block.ts processes every content block's bindings
map like this:

for (const binding in block.bindings) {
  const expression = block.bindings[binding];
  const value = evaluate({ code: expression, localState, rootState, rootSetState, context });
  set(copied, binding, value);
}

binding is the literal object key from the block's bindings map in the content JSON returned
by the Content API, used unvalidated as the write path for packages/sdks/src/functions/set.ts, a
hand-rolled reimplementation of lodash's _.set with no __proto__/prototype/constructor
guard. A content block whose bindings includes a key such as
"component.options.__proto__.isAdmin": "true" pollutes Object.prototype for the whole running
process (Node.js SSR and browser alike), affecting all subsequent objects created in that process
(other requests, other tenants' renders on the same SSR instance) until it restarts.

We confirmed this live against the unmodified source at commit
27e443e125c8e7f619335403cc0ecb01e03197a0, calling getProcessedBlock() with:

const maliciousBuilderBlock = {
  '@type': '@[builder.io/sdk:Element](http://builder.io/sdk:Element)',
  id: 'builder-abc123',
  component: { name: 'Text', options: { text: 'hello world' } },
  bindings: { 'component.options.__proto__.isAdmin': 'true' },
};

Before: ({}).isAdmin === undefined. After calling getProcessedBlock() once on that block:
({}).isAdmin === true, and a brand-new, completely unrelated object created afterwards also
returns true for .isAdmin. We further confirmed this crosses request/tenant boundaries: after
rendering the malicious block once, a second, unrelated, benign block (no bindings at all) rendered
afterwards in the same process comes back carrying the injected property too.

For comparison, the older Gen1 SDK's own query-string parser
(packages/core/src/classes/query-string.class.ts) already has an explicit deny-list for exactly
this (PROPERTY_NAME_DENY_LIST = ['__proto__', 'prototype', 'constructor']), so this looks like a
guard that was never carried over to the newer SDKs' set()/unflatten() utilities.

Suggested fix: add the same deny-list check to packages/sdks/src/functions/set.ts (and to
packages/sdks/src/helpers/flatten.ts's unflatten(), reported separately) before assigning into
a path segment, rejecting or skipping __proto__, prototype, and constructor segments.

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