Skip to content

Commit fb184de

Browse files
fix(backend): strip private_metadata from resource _raw in SSR sanitizer (#8702)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f6ac40f commit fb184de

3 files changed

Lines changed: 158 additions & 1 deletion

File tree

.changeset/proud-lions-allow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
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`.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { Organization } from '../../api/resources/Organization';
4+
import { User } from '../../api/resources/User';
5+
import { stripPrivateDataFromObject } from '../decorateObjectWithResources';
6+
7+
describe('stripPrivateDataFromObject', () => {
8+
it('removes top-level private metadata from user and organization', () => {
9+
const result = stripPrivateDataFromObject({
10+
user: { id: 'user_1', privateMetadata: { secret: 'a' } } as any,
11+
organization: { id: 'org_1', privateMetadata: { secret: 'b' } } as any,
12+
});
13+
14+
expect(result.user).not.toHaveProperty('privateMetadata');
15+
expect(result.organization).not.toHaveProperty('privateMetadata');
16+
});
17+
18+
it('strips private_metadata nested under the backend resource `_raw` payload', () => {
19+
const user = User.fromJSON({
20+
id: 'user_1',
21+
object: 'user',
22+
private_metadata: { ssn: '000-00-0000' },
23+
public_metadata: { plan: 'pro' },
24+
email_addresses: [],
25+
phone_numbers: [],
26+
web3_wallets: [],
27+
external_accounts: [],
28+
enterprise_accounts: [],
29+
} as any);
30+
31+
const organization = Organization.fromJSON({
32+
id: 'org_1',
33+
object: 'organization',
34+
name: 'Acme',
35+
slug: 'acme',
36+
private_metadata: { billingCustomerId: 'cus_secret' },
37+
public_metadata: { tier: 'enterprise' },
38+
} as any);
39+
40+
const result = stripPrivateDataFromObject({ user, organization });
41+
42+
// Serialize the way `buildClerkProps` embeds the state into the HTML response.
43+
const serialized = JSON.stringify(result);
44+
expect(serialized).not.toContain('000-00-0000');
45+
expect(serialized).not.toContain('cus_secret');
46+
47+
expect((result.user as any)._raw).not.toHaveProperty('private_metadata');
48+
expect((result.organization as any)._raw).not.toHaveProperty('private_metadata');
49+
50+
// Public metadata under `_raw` is intentionally preserved.
51+
expect((result.user as any)._raw.public_metadata).toEqual({ plan: 'pro' });
52+
expect((result.organization as any)._raw.public_metadata).toEqual({ tier: 'enterprise' });
53+
});
54+
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+
104+
it('does not mutate the original resource `raw` payload', () => {
105+
const user = User.fromJSON({
106+
id: 'user_1',
107+
object: 'user',
108+
private_metadata: { ssn: '000-00-0000' },
109+
email_addresses: [],
110+
phone_numbers: [],
111+
web3_wallets: [],
112+
external_accounts: [],
113+
enterprise_accounts: [],
114+
} as any);
115+
116+
stripPrivateDataFromObject({ user });
117+
118+
// The server-side `raw` getter must still expose the full payload.
119+
expect(user.raw?.private_metadata).toEqual({ ssn: '000-00-0000' });
120+
});
121+
});

packages/backend/src/util/decorateObjectWithResources.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function stripPrivateDataFromObject<T extends WithResources<object>>(auth
5252
return { ...authObject, user, organization };
5353
}
5454

55-
function prunePrivateMetadata(resource?: { private_metadata: any } | { privateMetadata: any } | null) {
55+
function prunePrivateMetadata(resource?: { private_metadata?: any; privateMetadata?: any; _raw?: any } | null) {
5656
// Delete sensitive private metadata from resource before rendering in SSR
5757
if (resource) {
5858
if ('privateMetadata' in resource) {
@@ -61,7 +61,38 @@ function prunePrivateMetadata(resource?: { private_metadata: any } | { privateMe
6161
if ('private_metadata' in resource) {
6262
delete resource['private_metadata'];
6363
}
64+
// Backend resources (`User`, `Organization`) retain the full Backend API
65+
// payload on the enumerable `_raw` property, which still contains
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.
71+
if ('_raw' in resource && resource['_raw']) {
72+
resource['_raw'] = redactPrivateMetadataDeep(resource['_raw']);
73+
}
6474
}
6575

6676
return resource;
6777
}
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)