From d3b2957db000772a408943d01382650821ce0950 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:16:32 +0530 Subject: [PATCH] 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 --- __tests__/e2e/.vitepress/config.ts | 6 ++ __tests__/e2e/local-search/index.md | 4 +- .../e2e/local-search/local-search.test.ts | 27 +++++ __tests__/unit/node/utils/fnSerialize.test.ts | 98 +++++++++++++++++++ src/node/build/build.ts | 7 +- src/node/plugin.ts | 9 +- src/node/utils/fnSerialize.ts | 25 +++-- 7 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 __tests__/unit/node/utils/fnSerialize.test.ts diff --git a/__tests__/e2e/.vitepress/config.ts b/__tests__/e2e/.vitepress/config.ts index acd74892..2a899efc 100644 --- a/__tests__/e2e/.vitepress/config.ts +++ b/__tests__/e2e/.vitepress/config.ts @@ -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 '' diff --git a/__tests__/e2e/local-search/index.md b/__tests__/e2e/local-search/index.md index 08d8b31a..04129895 100644 --- a/__tests__/e2e/local-search/index.md +++ b/__tests__/e2e/local-search/index.md @@ -1 +1,3 @@ -# Local search included \ No newline at end of file +# Local search included + +The custom tokenizer keeps #hash-probe and hyphen-linked-words whole. diff --git a/__tests__/e2e/local-search/local-search.test.ts b/__tests__/e2e/local-search/local-search.test.ts index 07ec1926..fe507e5b 100644 --- a/__tests__/e2e/local-search/local-search.test.ts +++ b/__tests__/e2e/local-search/local-search.test.ts @@ -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 [ diff --git a/__tests__/unit/node/utils/fnSerialize.test.ts b/__tests__/unit/node/utils/fnSerialize.test.ts new file mode 100644 index 00000000..04db1bca --- /dev/null +++ b/__tests__/unit/node/utils/fnSerialize.test.ts @@ -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)') + }) +}) diff --git a/src/node/build/build.ts b/src/node/build/build.ts index 12fa1abb..5bd5f5a1 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -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});` }` diff --git a/src/node/plugin.ts b/src/node/plugin.ts index ec2ffd9d..42f1f9d4 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -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})` } }, diff --git a/src/node/utils/fnSerialize.ts b/src/node/utils/fnSerialize.ts index da4f3680..74a1d5f3 100644 --- a/src/node/utils/fnSerialize.ts +++ b/src/node/utils/fnSerialize.ts @@ -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 }