Skip to content

Commit cc7bcbc

Browse files
dominic-clerkclaude
andcommitted
fix(backend): recursively strip private_metadata from resource _raw in SSR sanitizer
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 <noreply@anthropic.com>
1 parent 33198d5 commit cc7bcbc

2 files changed

Lines changed: 76 additions & 5 deletions

File tree

packages/backend/src/util/__tests__/decorateObjectWithResources.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,55 @@ describe('stripPrivateDataFromObject', () => {
5252
expect((result.organization as any)._raw.public_metadata).toEqual({ tier: 'enterprise' });
5353
});
5454

55+
it('recursively strips private_metadata nested under `_raw.organization_memberships`', () => {
56+
const user = User.fromJSON({
57+
id: 'user_1',
58+
object: 'user',
59+
private_metadata: { ssn: '000-00-0000' },
60+
public_metadata: { plan: 'pro' },
61+
email_addresses: [],
62+
phone_numbers: [],
63+
web3_wallets: [],
64+
external_accounts: [],
65+
enterprise_accounts: [],
66+
organization_memberships: [
67+
{
68+
id: 'orgmem_1',
69+
object: 'organization_membership',
70+
role: 'admin',
71+
permissions: [],
72+
private_metadata: { membershipSecret: 'mem_secret' },
73+
public_metadata: { seat: 'a' },
74+
created_at: 1,
75+
updated_at: 1,
76+
organization: {
77+
id: 'org_1',
78+
object: 'organization',
79+
name: 'Acme',
80+
slug: 'acme',
81+
private_metadata: { billingCustomerId: 'cus_secret' },
82+
public_metadata: { tier: 'enterprise' },
83+
},
84+
},
85+
],
86+
} as any);
87+
88+
const result = stripPrivateDataFromObject({ user });
89+
90+
const serialized = JSON.stringify(result);
91+
expect(serialized).not.toContain('000-00-0000');
92+
expect(serialized).not.toContain('mem_secret');
93+
expect(serialized).not.toContain('cus_secret');
94+
95+
const membership = (result.user as any)._raw.organization_memberships[0];
96+
expect(membership).not.toHaveProperty('private_metadata');
97+
expect(membership.organization).not.toHaveProperty('private_metadata');
98+
99+
// Public metadata throughout the nested payload is intentionally preserved.
100+
expect(membership.public_metadata).toEqual({ seat: 'a' });
101+
expect(membership.organization.public_metadata).toEqual({ tier: 'enterprise' });
102+
});
103+
55104
it('does not mutate the original resource `raw` payload', () => {
56105
const user = User.fromJSON({
57106
id: 'user_1',

packages/backend/src/util/decorateObjectWithResources.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,36 @@ function prunePrivateMetadata(resource?: { private_metadata?: any; privateMetada
6363
}
6464
// Backend resources (`User`, `Organization`) retain the full Backend API
6565
// payload on the enumerable `_raw` property, which still contains
66-
// `private_metadata`. Strip it from a shallow clone so the original
67-
// resource (and its `raw` getter) is left untouched.
66+
// `private_metadata`. The payload is also nested (e.g. a `User`'s
67+
// `organization_memberships[*]` each carry their own `private_metadata`
68+
// and a nested `organization.private_metadata`), so redact recursively on
69+
// a deep clone — leaving the original resource (and its `raw` getter)
70+
// untouched.
6871
if ('_raw' in resource && resource['_raw']) {
69-
const raw = { ...resource['_raw'] };
70-
delete raw['private_metadata'];
71-
resource['_raw'] = raw;
72+
resource['_raw'] = redactPrivateMetadataDeep(resource['_raw']);
7273
}
7374
}
7475

7576
return resource;
7677
}
78+
79+
/**
80+
* Returns a deep clone of `value` with every `private_metadata` / `privateMetadata`
81+
* property removed at any depth.
82+
*/
83+
function redactPrivateMetadataDeep(value: any): any {
84+
if (Array.isArray(value)) {
85+
return value.map(redactPrivateMetadataDeep);
86+
}
87+
if (value && typeof value === 'object') {
88+
const clone: Record<string, any> = {};
89+
for (const key of Object.keys(value)) {
90+
if (key === 'private_metadata' || key === 'privateMetadata') {
91+
continue;
92+
}
93+
clone[key] = redactPrivateMetadataDeep(value[key]);
94+
}
95+
return clone;
96+
}
97+
return value;
98+
}

0 commit comments

Comments
 (0)