From 33198d5c4fdbd278cacb5be4810261444a7d50bf Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Fri, 29 May 2026 12:55:53 +0100 Subject: [PATCH 1/2] fix(backend): strip private_metadata from resource _raw in SSR sanitizer stripPrivateDataFromObject now removes private_metadata from the backend resource _raw payload, preventing it from leaking into __clerk_ssr_state when a User/Organization resource is passed to buildClerkProps. Co-Authored-By: Claude Opus 4.8 --- .changeset/proud-lions-allow.md | 5 ++ .../decorateObjectWithResources.test.ts | 72 +++++++++++++++++++ .../src/util/decorateObjectWithResources.ts | 11 ++- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .changeset/proud-lions-allow.md create mode 100644 packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts diff --git a/.changeset/proud-lions-allow.md b/.changeset/proud-lions-allow.md new file mode 100644 index 00000000000..3943fba931a --- /dev/null +++ b/.changeset/proud-lions-allow.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Strip `private_metadata` from the backend resource `_raw` payload in `stripPrivateDataFromObject`, preventing it from leaking into `__clerk_ssr_state` when a `User`/`Organization` resource is passed to `buildClerkProps`. diff --git a/packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts b/packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts new file mode 100644 index 00000000000..8195b3ad092 --- /dev/null +++ b/packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { Organization } from '../../api/resources/Organization'; +import { User } from '../../api/resources/User'; +import { stripPrivateDataFromObject } from '../decorateObjectWithResources'; + +describe('stripPrivateDataFromObject', () => { + it('removes top-level private metadata from user and organization', () => { + const result = stripPrivateDataFromObject({ + user: { id: 'user_1', privateMetadata: { secret: 'a' } } as any, + organization: { id: 'org_1', privateMetadata: { secret: 'b' } } as any, + }); + + expect(result.user).not.toHaveProperty('privateMetadata'); + expect(result.organization).not.toHaveProperty('privateMetadata'); + }); + + it('strips private_metadata nested under the backend resource `_raw` payload', () => { + const user = User.fromJSON({ + id: 'user_1', + object: 'user', + private_metadata: { ssn: '000-00-0000' }, + public_metadata: { plan: 'pro' }, + email_addresses: [], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + enterprise_accounts: [], + } as any); + + const organization = Organization.fromJSON({ + id: 'org_1', + object: 'organization', + name: 'Acme', + slug: 'acme', + private_metadata: { billingCustomerId: 'cus_secret' }, + public_metadata: { tier: 'enterprise' }, + } as any); + + const result = stripPrivateDataFromObject({ user, organization }); + + // Serialize the way `buildClerkProps` embeds the state into the HTML response. + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('000-00-0000'); + expect(serialized).not.toContain('cus_secret'); + + expect((result.user as any)._raw).not.toHaveProperty('private_metadata'); + expect((result.organization as any)._raw).not.toHaveProperty('private_metadata'); + + // Public metadata under `_raw` is intentionally preserved. + expect((result.user as any)._raw.public_metadata).toEqual({ plan: 'pro' }); + expect((result.organization as any)._raw.public_metadata).toEqual({ tier: 'enterprise' }); + }); + + it('does not mutate the original resource `raw` payload', () => { + const user = User.fromJSON({ + id: 'user_1', + object: 'user', + private_metadata: { ssn: '000-00-0000' }, + email_addresses: [], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + enterprise_accounts: [], + } as any); + + stripPrivateDataFromObject({ user }); + + // The server-side `raw` getter must still expose the full payload. + expect(user.raw?.private_metadata).toEqual({ ssn: '000-00-0000' }); + }); +}); diff --git a/packages/backend/src/util/decorateObjectWithResources.ts b/packages/backend/src/util/decorateObjectWithResources.ts index 925cb39e4de..a3d308b8918 100644 --- a/packages/backend/src/util/decorateObjectWithResources.ts +++ b/packages/backend/src/util/decorateObjectWithResources.ts @@ -52,7 +52,7 @@ export function stripPrivateDataFromObject>(auth return { ...authObject, user, organization }; } -function prunePrivateMetadata(resource?: { private_metadata: any } | { privateMetadata: any } | null) { +function prunePrivateMetadata(resource?: { private_metadata?: any; privateMetadata?: any; _raw?: any } | null) { // Delete sensitive private metadata from resource before rendering in SSR if (resource) { if ('privateMetadata' in resource) { @@ -61,6 +61,15 @@ function prunePrivateMetadata(resource?: { private_metadata: any } | { privateMe if ('private_metadata' in resource) { delete resource['private_metadata']; } + // Backend resources (`User`, `Organization`) retain the full Backend API + // payload on the enumerable `_raw` property, which still contains + // `private_metadata`. Strip it from a shallow clone so the original + // resource (and its `raw` getter) is left untouched. + if ('_raw' in resource && resource['_raw']) { + const raw = { ...resource['_raw'] }; + delete raw['private_metadata']; + resource['_raw'] = raw; + } } return resource; From cc7bcbc09fb25625bd7b92271bb0609287eb99f0 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Fri, 29 May 2026 17:33:08 +0100 Subject: [PATCH 2/2] fix(backend): recursively strip private_metadata from resource _raw in SSR sanitizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix only removed the top-level `_raw.private_metadata`, but a `User`'s `_raw` payload nests further private metadata — each `organization_memberships[*]` carries its own `private_metadata` plus a nested `organization.private_metadata` — which still serialized into `__clerk_ssr_state`. Redact `private_metadata`/`privateMetadata` recursively on a deep clone of `_raw` so nested fields are stripped while the original resource (and its `raw` getter) is left untouched. Adds a regression test for the `organization_memberships` shape. Co-Authored-By: Claude Opus 4.8 --- .../decorateObjectWithResources.test.ts | 49 +++++++++++++++++++ .../src/util/decorateObjectWithResources.ts | 32 ++++++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts b/packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts index 8195b3ad092..482997bbc7b 100644 --- a/packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts +++ b/packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts @@ -52,6 +52,55 @@ describe('stripPrivateDataFromObject', () => { expect((result.organization as any)._raw.public_metadata).toEqual({ tier: 'enterprise' }); }); + it('recursively strips private_metadata nested under `_raw.organization_memberships`', () => { + const user = User.fromJSON({ + id: 'user_1', + object: 'user', + private_metadata: { ssn: '000-00-0000' }, + public_metadata: { plan: 'pro' }, + email_addresses: [], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + enterprise_accounts: [], + organization_memberships: [ + { + id: 'orgmem_1', + object: 'organization_membership', + role: 'admin', + permissions: [], + private_metadata: { membershipSecret: 'mem_secret' }, + public_metadata: { seat: 'a' }, + created_at: 1, + updated_at: 1, + organization: { + id: 'org_1', + object: 'organization', + name: 'Acme', + slug: 'acme', + private_metadata: { billingCustomerId: 'cus_secret' }, + public_metadata: { tier: 'enterprise' }, + }, + }, + ], + } as any); + + const result = stripPrivateDataFromObject({ user }); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('000-00-0000'); + expect(serialized).not.toContain('mem_secret'); + expect(serialized).not.toContain('cus_secret'); + + const membership = (result.user as any)._raw.organization_memberships[0]; + expect(membership).not.toHaveProperty('private_metadata'); + expect(membership.organization).not.toHaveProperty('private_metadata'); + + // Public metadata throughout the nested payload is intentionally preserved. + expect(membership.public_metadata).toEqual({ seat: 'a' }); + expect(membership.organization.public_metadata).toEqual({ tier: 'enterprise' }); + }); + it('does not mutate the original resource `raw` payload', () => { const user = User.fromJSON({ id: 'user_1', diff --git a/packages/backend/src/util/decorateObjectWithResources.ts b/packages/backend/src/util/decorateObjectWithResources.ts index a3d308b8918..6478ff9c946 100644 --- a/packages/backend/src/util/decorateObjectWithResources.ts +++ b/packages/backend/src/util/decorateObjectWithResources.ts @@ -63,14 +63,36 @@ function prunePrivateMetadata(resource?: { private_metadata?: any; privateMetada } // Backend resources (`User`, `Organization`) retain the full Backend API // payload on the enumerable `_raw` property, which still contains - // `private_metadata`. Strip it from a shallow clone so the original - // resource (and its `raw` getter) is left untouched. + // `private_metadata`. The payload is also nested (e.g. a `User`'s + // `organization_memberships[*]` each carry their own `private_metadata` + // and a nested `organization.private_metadata`), so redact recursively on + // a deep clone — leaving the original resource (and its `raw` getter) + // untouched. if ('_raw' in resource && resource['_raw']) { - const raw = { ...resource['_raw'] }; - delete raw['private_metadata']; - resource['_raw'] = raw; + resource['_raw'] = redactPrivateMetadataDeep(resource['_raw']); } } return resource; } + +/** + * Returns a deep clone of `value` with every `private_metadata` / `privateMetadata` + * property removed at any depth. + */ +function redactPrivateMetadataDeep(value: any): any { + if (Array.isArray(value)) { + return value.map(redactPrivateMetadataDeep); + } + if (value && typeof value === 'object') { + const clone: Record = {}; + for (const key of Object.keys(value)) { + if (key === 'private_metadata' || key === 'privateMetadata') { + continue; + } + clone[key] = redactPrivateMetadataDeep(value[key]); + } + return clone; + } + return value; +}