fix: serialize config functions as code, not strings revived via new Function (close #3685)

Functions in the site config (e.g. a custom miniSearch tokenize) were
embedded in the client's JSON payload as source strings and rebuilt at
runtime with new Function, which strict CSP blocks unless unsafe-eval
is allowed. Emit them as plain function expressions in the generated
script instead, with only an index marker left in the JSON, so the
deserializer just looks them up.

This also stops data strings that merely start with "_vp-fn_" from
being evaluated as code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5408/head
Divyansh Singh 2 weeks ago
parent feadd9fcc1
commit d3b2957db0

@ -215,6 +215,12 @@ export default defineConfig({
search: {
provider: 'local',
options: {
miniSearch: {
options: {
tokenize: (text) =>
text.split(/[\n\r\p{Z}\p{Terminal_Punctuation}]+/u)
}
},
async _render(src, env, md) {
const html = await md.renderAsync(src, env)
if (env.frontmatter?.search === false) return ''

@ -1 +1,3 @@
# Local search included
The custom tokenizer keeps #hash-probe and hyphen-linked-words whole.

@ -83,6 +83,33 @@ describe('local search', () => {
).toBe(0)
})
test('custom tokenize function reaches the client', async () => {
await page.locator('.VPNavBarSearchButton').click()
const input = await page.waitForSelector('input#localsearch-input')
// '#hash-probe' survives as one token only under the custom tokenizer —
// MiniSearch's default one would degrade the query to 'hash'/'probe'
// and miss the index built with the custom tokenizer
await input.type('#hash-probe')
await page.waitForFunction(() => {
const options = [
...document.querySelectorAll('#localsearch-list li[role=option]')
]
return (
options.length === 1 &&
options[0].textContent?.includes('Local search included')
)
})
// a fragment of a kept-whole token must not match anything
await input.fill('linked-words')
await page.waitForSelector('.no-results')
})
test('uses the same desktop breakpoint as the nav bar', async () => {
try {
for (const { width, isDesktop } of [

@ -0,0 +1,98 @@
import {
deserializeFunctions,
serializeFunctions
} from 'node/utils/fnSerialize'
// runs the exact code shape that plugin.ts / build.ts emit into the site-data
// module and the metadata script — the revived value must come back without
// the deserializer ever compiling a string (new Function is used here only to
// stand in for the browser executing the emitted file)
function emitAndRevive(data: any): any {
const fns: string[] = []
const serialized = serializeFunctions(data, fns)
const script = `${deserializeFunctions};return deserializeFunctions(JSON.parse(${JSON.stringify(
JSON.stringify(serialized)
)}),[${fns.join(',')}])`
return new Function(script)()
}
describe('node/utils/fnSerialize', () => {
test('emitted deserializer does not rely on unsafe-eval', () => {
expect(deserializeFunctions).not.toContain('new Function')
expect(deserializeFunctions).not.toContain('eval')
})
test('serializes functions as indexed markers', () => {
const fns: string[] = []
const serialized = serializeFunctions(
{ a: (x: number) => x, b: { c: (x: number) => x * 2 } },
fns
)
expect(serialized).toEqual({ a: '_vp-fn_0', b: { c: '_vp-fn_1' } })
expect(fns).toHaveLength(2)
})
test('revives functions nested in objects and arrays', () => {
const data = {
search: {
options: {
miniSearch: {
options: {
tokenize: (text: string) => text.split(/\s+/)
},
searchOptions: {
boostDocument: (id: string) => (id === 'index.md' ? 2 : 1)
}
}
}
},
list: [(n: number) => n + 1, 'plain', 42]
}
const revived = emitAndRevive(data)
expect(revived.search.options.miniSearch.options.tokenize('a b')).toEqual([
'a',
'b'
])
expect(
revived.search.options.miniSearch.searchOptions.boostDocument('index.md')
).toBe(2)
expect(revived.list[0](1)).toBe(2)
expect(revived.list[1]).toBe('plain')
expect(revived.list[2]).toBe(42)
})
test('revives method shorthand and async functions', () => {
const data = {
tokenize(text: string) {
return text.toUpperCase()
},
async extractField(doc: { id: string }) {
return doc.id
}
}
const revived = emitAndRevive(data)
expect(revived.tokenize('abc')).toBe('ABC')
return expect(revived.extractField({ id: 'x' })).resolves.toBe('x')
})
test('drops underscore-prefixed keys', () => {
const revived = emitAndRevive({ _render: () => '', keep: 1 })
expect(revived).toEqual({ keep: 1 })
})
test('leaves data strings resembling markers untouched', () => {
const data = {
fn: (x: number) => x,
note: '_vp-fn_alert(1)'
}
const revived = emitAndRevive(data)
expect(revived.fn(1)).toBe(1)
expect(revived.note).toBe('_vp-fn_alert(1)')
})
})

@ -267,13 +267,14 @@ async function generateMetadataScript(
// It's also embedded as a string and JSON.parsed from the client because
// it's faster than embedding as JS object literal.
const hashMapString = JSON.stringify(JSON.stringify(pageToHashMap))
const fns: string[] = []
const siteDataString = JSON.stringify(
JSON.stringify(serializeFunctions({ ...config.site, head: [] }))
JSON.stringify(serializeFunctions({ ...config.site, head: [] }, fns))
)
const metadataContent = `window.__VP_HASH_MAP__=JSON.parse(${hashMapString});${
siteDataString.includes('_vp-fn_')
? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}));`
fns.length
? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}),[${fns.join(',')}]);`
: `window.__VP_SITE_DATA__=JSON.parse(${siteDataString});`
}`

@ -206,8 +206,13 @@ export async function createVitePressPlugin(
return `export default window.__VP_SITE_DATA__`
}
}
data = serializeFunctions(data)
return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(JSON.stringify(data))}))`
const fns: string[] = []
const dataStr = JSON.stringify(
JSON.stringify(serializeFunctions(data, fns))
)
return fns.length
? `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${dataStr}),[${fns.join(',')}])`
: `export default JSON.parse(${dataStr})`
}
},

@ -1,30 +1,37 @@
/*
export function deserializeFunctions(value: any): any {
export function deserializeFunctions(value: any, fns: any[]): any {
if (Array.isArray(value)) {
return value.map(deserializeFunctions)
return value.map((v) => deserializeFunctions(v, fns))
} else if (typeof value === 'object' && value !== null) {
return Object.keys(value).reduce((acc, key) => {
acc[key] = deserializeFunctions(value[key])
acc[key] = deserializeFunctions(value[key], fns)
return acc
}, {} as any)
} else if (typeof value === 'string' && value.startsWith('_vp-fn_')) {
return new Function(`return ${value.slice(7)}`)()
return fns[+value.slice(7)] ?? value
} else {
return value
}
}
*/
// functions are emitted as plain code next to this and only looked up here by
// index, so no `new Function` is needed and strict CSP (no `unsafe-eval`)
// stays intact (#3685)
export const deserializeFunctions =
'function deserializeFunctions(r){return Array.isArray(r)?r.map(deserializeFunctions):typeof r=="object"&&r!==null?Object.keys(r).reduce((t,n)=>(t[n]=deserializeFunctions(r[n]),t),{}):typeof r=="string"&&r.startsWith("_vp-fn_")?new Function(`return ${r.slice(7)}`)():r}'
'function deserializeFunctions(r,e){return Array.isArray(r)?r.map(t=>deserializeFunctions(t,e)):typeof r=="object"&&r!==null?Object.keys(r).reduce((t,n)=>(t[n]=deserializeFunctions(r[n],e),t),{}):typeof r=="string"&&r.startsWith("_vp-fn_")?e[+r.slice(7)]??r:r}'
export function serializeFunctions(value: any, key?: string): any {
export function serializeFunctions(
value: any,
fns: string[],
key?: string
): any {
if (Array.isArray(value)) {
return value.map((v) => serializeFunctions(v))
return value.map((v) => serializeFunctions(v, fns))
} else if (typeof value === 'object' && value !== null) {
return Object.keys(value).reduce((acc, key) => {
if (key[0] === '_') return acc
acc[key] = serializeFunctions(value[key], key)
acc[key] = serializeFunctions(value[key], fns, key)
return acc
}, {} as any)
} else if (typeof value === 'function') {
@ -35,7 +42,7 @@ export function serializeFunctions(value: any, key?: string): any {
) {
serialized = serialized.replace(key, 'function')
}
return `_vp-fn_${serialized}`
return `_vp-fn_${fns.push(`(${serialized})`) - 1}`
} else {
return value
}

Loading…
Cancel
Save