Skip to content

Commit a6ed9f9

Browse files
committed
fix: harden prototype pollution
1 parent 59ef1e2 commit a6ed9f9

File tree

8 files changed

+200
-8
lines changed

8 files changed

+200
-8
lines changed

packages/angular/server/src/ssr.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function attrToElement(element: HTMLElement, acc: string) {
1515
else if (key === 'style') {
1616
const styleObj = value.split(';').reduce((acc, style) => {
1717
const [prop, val] = style.split(':').map(s => s.trim())
18-
if (prop && val)
18+
if (prop && val && prop !== '__proto__' && prop !== 'constructor' && prop !== 'prototype')
1919
acc[prop] = val
2020
return acc
2121
}, {} as Record<string, string>)

packages/schema-org/src/core/graph.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export function createSchemaOrgGraph(): SchemaOrgGraph {
8383
}
8484

8585
// Dedupe and build nodeIndex in single pass
86-
const dedupedNodes: Record<Id, SchemaOrgNode> = {}
86+
const dedupedNodes: Record<Id, SchemaOrgNode> = Object.create(null)
8787
ctx.nodeIndex = new Map()
8888
for (let i = 0; i < ctx.nodes.length; i++) {
8989
const n = ctx.nodes[i]
@@ -128,7 +128,7 @@ export function createSchemaOrgGraph(): SchemaOrgGraph {
128128

129129
// Final normalization: sort keys and dedupe only if new nodes were added
130130
const needsDedupe = ctx.nodes.length > countBeforeRelations
131-
const normalizedNodes: Record<Id, SchemaOrgNode> = needsDedupe ? {} : null!
131+
const normalizedNodes: Record<Id, SchemaOrgNode> = needsDedupe ? Object.create(null) : null!
132132
const result: SchemaOrgNode[] = needsDedupe ? null! : []
133133

134134
for (let i = 0; i < ctx.nodes.length; i++) {
@@ -174,7 +174,7 @@ export function createSchemaOrgGraph(): SchemaOrgGraph {
174174
},
175175
nodes: [],
176176
nodeIndex: new Map(),
177-
nodeIdCounters: {},
177+
nodeIdCounters: Object.create(null),
178178
meta: {} as ResolvedMeta,
179179
}
180180
return ctx

packages/schema-org/src/core/util.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
2+
13
export function merge(target: any, source: any): any {
24
if (!source)
35
return target
46

57
for (const key in source) {
6-
if (!Object.prototype.hasOwnProperty.call(source, key))
8+
if (!Object.hasOwn(source, key) || UNSAFE_KEYS.has(key))
79
continue
810

911
const value = source[key]
@@ -28,7 +30,7 @@ export function merge(target: any, source: any): any {
2830
}
2931
// potentialAction - dedupe by @type, merge targets
3032
else if (key === 'potentialAction') {
31-
const byType: Record<string, any> = {}
33+
const byType: Record<string, any> = Object.create(null)
3234
for (const action of merged) {
3335
const type = action['@type']
3436
if (byType[type]) {

packages/schema-org/src/plugin.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
import { resolveMeta } from './core/resolve'
99
import { loadResolver } from './resolver'
1010

11+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
12+
1113
// Recursively collect all resolver strings from nested objects and preload them
1214
async function preloadNestedResolvers(obj: any): Promise<void> {
1315
if (!obj || typeof obj !== 'object')
@@ -24,6 +26,8 @@ async function preloadNestedResolvers(obj: any): Promise<void> {
2426
}
2527

2628
for (const key in obj) {
29+
if (!Object.hasOwn(obj, key) || UNSAFE_KEYS.has(key))
30+
continue
2731
const val = obj[key]
2832
if (val && typeof val === 'object') {
2933
if (Array.isArray(val)) {
@@ -44,7 +48,7 @@ async function preloadNestedResolvers(obj: any): Promise<void> {
4448
function mergeObjects(target: any, source: any): any {
4549
const result = { ...target }
4650
for (const key in source) {
47-
if (!Object.prototype.hasOwnProperty.call(source, key) || source[key] === undefined)
51+
if (!Object.hasOwn(source, key) || source[key] === undefined || UNSAFE_KEYS.has(key))
4852
continue
4953

5054
const isNestedObject = result[key]
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { defineOrganization, defineWebPage, useSchemaOrg } from '@unhead/schema-org'
2+
import { renderSSRHead } from '@unhead/ssr'
3+
import { describe, expect, it } from 'vitest'
4+
import { useSetup } from '..'
5+
6+
describe('schema.org prototype pollution', () => {
7+
it('merge strips __proto__ from schema nodes', async () => {
8+
const ssrHead = await useSetup((head) => {
9+
useSchemaOrg(head, [
10+
defineWebPage({
11+
name: 'test',
12+
...JSON.parse('{"__proto__":{"polluted":true}}'),
13+
}),
14+
])
15+
})
16+
17+
await renderSSRHead(ssrHead)
18+
expect(({} as any).polluted).toBeUndefined()
19+
})
20+
21+
it('merge strips constructor from schema nodes', async () => {
22+
const ssrHead = await useSetup((head) => {
23+
useSchemaOrg(head, [
24+
defineOrganization({
25+
name: 'Test Org',
26+
constructor: { prototype: { polluted: true } },
27+
} as any),
28+
])
29+
})
30+
31+
await renderSSRHead(ssrHead)
32+
expect(({} as any).polluted).toBeUndefined()
33+
})
34+
35+
it('merge strips prototype from schema nodes', async () => {
36+
const ssrHead = await useSetup((head) => {
37+
useSchemaOrg(head, [
38+
defineWebPage({
39+
name: 'test',
40+
prototype: { polluted: true },
41+
} as any),
42+
])
43+
})
44+
45+
await renderSSRHead(ssrHead)
46+
expect(({} as any).polluted).toBeUndefined()
47+
})
48+
49+
it('preserves valid schema data while stripping dangerous keys', async () => {
50+
const ssrHead = await useSetup((head) => {
51+
useSchemaOrg(head, [
52+
defineWebPage({
53+
name: 'My Page',
54+
description: 'A safe description',
55+
...JSON.parse('{"__proto__":{"polluted":true}}'),
56+
}),
57+
])
58+
})
59+
60+
const data = await renderSSRHead(ssrHead)
61+
expect(data.bodyTags).toContain('"name": "My Page"')
62+
expect(data.bodyTags).toContain('"description": "A safe description"')
63+
expect(data.bodyTags).not.toContain('__proto__')
64+
expect(data.bodyTags).not.toContain('polluted')
65+
expect(({} as any).polluted).toBeUndefined()
66+
})
67+
68+
it('handles __proto__ in deeply nested objects', async () => {
69+
const ssrHead = await useSetup((head) => {
70+
useSchemaOrg(head, [
71+
defineOrganization({
72+
name: 'Test Org',
73+
address: JSON.parse('{"streetAddress":"123 Main St","__proto__":{"polluted":true}}'),
74+
} as any),
75+
])
76+
})
77+
78+
await renderSSRHead(ssrHead)
79+
expect(({} as any).polluted).toBeUndefined()
80+
})
81+
})

packages/unhead/src/plugins/safe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ function stripProtoKeys(obj: any): any {
9797
if (obj && typeof obj === 'object') {
9898
const clean: Record<string, any> = {}
9999
for (const key of Object.keys(obj)) {
100-
if (key === '__proto__' || key === 'constructor')
100+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
101101
continue
102102
clean[key] = stripProtoKeys(obj[key])
103103
}

packages/unhead/src/utils/normalize.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export function normalizeProps(tag: HeadTag, input: Record<string, any>): HeadTa
6060
}
6161

6262
Object.entries(input).forEach(([key, value]) => {
63+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
64+
return
6365
// if the value is a primitive, return early
6466
if (value === null) {
6567
// @ts-expect-error untyped
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { renderSSRHead } from '../../../src/server'
3+
import { createServerHeadWithContext } from '../../util'
4+
5+
describe('prototype pollution', () => {
6+
it('strips __proto__ from meta props', async () => {
7+
const head = createServerHeadWithContext()
8+
head.push({
9+
meta: [
10+
JSON.parse('{"name":"description","content":"safe","__proto__":{"polluted":true}}'),
11+
],
12+
})
13+
await renderSSRHead(head)
14+
expect(({} as any).polluted).toBeUndefined()
15+
})
16+
17+
it('strips constructor from meta props', async () => {
18+
const head = createServerHeadWithContext()
19+
head.push({
20+
meta: [
21+
{ name: 'description', content: 'safe', constructor: { prototype: { polluted: true } } } as any,
22+
],
23+
})
24+
await renderSSRHead(head)
25+
expect(({} as any).polluted).toBeUndefined()
26+
})
27+
28+
it('strips prototype from meta props', async () => {
29+
const head = createServerHeadWithContext()
30+
head.push({
31+
meta: [
32+
{ name: 'description', content: 'safe', prototype: { polluted: true } } as any,
33+
],
34+
})
35+
await renderSSRHead(head)
36+
expect(({} as any).polluted).toBeUndefined()
37+
})
38+
39+
it('strips __proto__ from htmlAttrs', async () => {
40+
const head = createServerHeadWithContext()
41+
head.push({
42+
htmlAttrs: JSON.parse('{"lang":"en","__proto__":{"polluted":true}}'),
43+
})
44+
await renderSSRHead(head)
45+
expect(({} as any).polluted).toBeUndefined()
46+
})
47+
48+
it('strips __proto__ from bodyAttrs', async () => {
49+
const head = createServerHeadWithContext()
50+
head.push({
51+
bodyAttrs: JSON.parse('{"class":"dark","__proto__":{"polluted":true}}'),
52+
})
53+
await renderSSRHead(head)
54+
expect(({} as any).polluted).toBeUndefined()
55+
})
56+
57+
it('strips __proto__ from script props', async () => {
58+
const head = createServerHeadWithContext()
59+
head.push({
60+
script: [
61+
JSON.parse('{"src":"https://example.com/app.js","__proto__":{"polluted":true}}'),
62+
],
63+
})
64+
await renderSSRHead(head)
65+
expect(({} as any).polluted).toBeUndefined()
66+
})
67+
68+
it('strips __proto__ from link props', async () => {
69+
const head = createServerHeadWithContext()
70+
head.push({
71+
link: [
72+
JSON.parse('{"rel":"stylesheet","href":"/style.css","__proto__":{"polluted":true}}'),
73+
],
74+
})
75+
await renderSSRHead(head)
76+
expect(({} as any).polluted).toBeUndefined()
77+
})
78+
79+
it('preserves valid props while stripping dangerous keys', async () => {
80+
const head = createServerHeadWithContext()
81+
head.push({
82+
meta: [
83+
JSON.parse('{"name":"description","content":"hello","__proto__":{"polluted":true}}'),
84+
],
85+
})
86+
const ctx = await renderSSRHead(head)
87+
expect(ctx.headTags).toContain('name="description"')
88+
expect(ctx.headTags).toContain('content="hello"')
89+
expect(ctx.headTags).not.toContain('__proto__')
90+
expect(ctx.headTags).not.toContain('polluted')
91+
})
92+
93+
it('strips nested __proto__ in style objects', async () => {
94+
const head = createServerHeadWithContext()
95+
head.push({
96+
htmlAttrs: {
97+
style: JSON.parse('{"color":"red","__proto__":{"polluted":true}}'),
98+
} as any,
99+
})
100+
await renderSSRHead(head)
101+
expect(({} as any).polluted).toBeUndefined()
102+
})
103+
})

0 commit comments

Comments
 (0)