diff --git a/config/build-plugins/preload-sandbox-builtin-guard.test.ts b/config/build-plugins/preload-sandbox-builtin-guard.test.ts index baf64146e2a..d05c59d7c62 100644 --- a/config/build-plugins/preload-sandbox-builtin-guard.test.ts +++ b/config/build-plugins/preload-sandbox-builtin-guard.test.ts @@ -110,6 +110,71 @@ describe('preload sandbox guard detection', () => { }) }) +// Why: the case above only covers identifier prefixes, a quoted *static import statement* off +// statement position, and member-access `require`. None of them exercise call syntax inside a +// literal, so the guard used to fail the build on comments and ordinary strings. +describe('preload sandbox guard lexical masking', () => { + it('ignores a module call quoted inside a string literal', () => { + expect(findPreloadSandboxViolations(`const help = "Call require('fs')"`)).toEqual([]) + }) + + it('ignores a module call in a line comment, which survives an unminified chunk', () => { + expect(findPreloadSandboxViolations("// use require('fs')\nexport {}")).toEqual([]) + }) + + it('ignores a module call in a block comment', () => { + expect(findPreloadSandboxViolations("/* see require('fs')\n and import('path') */")).toEqual( + [] + ) + }) + + it('ignores a module call inside a template literal', () => { + expect(findPreloadSandboxViolations('const t = `see import("path") here`')).toEqual([]) + }) + + it('ignores a fake require after an escaped quote, which naive masking would end early', () => { + expect(findPreloadSandboxViolations('const s = "a\\"require(\'fs\')\\"b"')).toEqual([]) + }) + + it('still flags a real request inside a template interpolation', () => { + expect(findPreloadSandboxViolations('const t = `${require("fs")}`')).toEqual([ + { kind: 'node-builtin', detail: 'fs' } + ]) + }) + + it('still flags a real request nested in an interpolated object literal', () => { + expect(findPreloadSandboxViolations('const t = `${({ a: require("net") }).a}` + "x"')).toEqual([ + { kind: 'node-builtin', detail: 'net' } + ]) + }) + + it('still flags a real request on the line after a comment', () => { + expect(findPreloadSandboxViolations('// hi\nconst x = require("node:os")')).toEqual([ + { kind: 'node-builtin', detail: 'node:os' } + ]) + }) + + // Why: same line on purpose — an unterminated string already stops at a newline, so only a + // same-line regex can swallow the request that follows it. + it('still flags a real request after a regex holding an unpaired quote', () => { + expect(findPreloadSandboxViolations('const q = /["]/; const x = require("node:zlib")')).toEqual( + [{ kind: 'node-builtin', detail: 'node:zlib' }] + ) + }) + + it('still flags a real request after a division, which is not a regex', () => { + expect(findPreloadSandboxViolations('const r = t / 2; const x = require("node:dns")')).toEqual([ + { kind: 'node-builtin', detail: 'node:dns' } + ]) + }) + + it('still flags a real static import that follows a quoted decoy', () => { + expect(findPreloadSandboxViolations('const d = "require(\'fs\')";\nimport "node:tty"')).toEqual( + [{ kind: 'node-builtin', detail: 'node:tty' }] + ) + }) +}) + describe('preload sandbox guard assertions', () => { it('names every offending specifier and the supported surface', () => { expect(() => diff --git a/config/build-plugins/preload-sandbox-builtin-guard.ts b/config/build-plugins/preload-sandbox-builtin-guard.ts index fb75756af71..e191cf16cc6 100644 --- a/config/build-plugins/preload-sandbox-builtin-guard.ts +++ b/config/build-plugins/preload-sandbox-builtin-guard.ts @@ -63,19 +63,123 @@ function collect( } } +/** A NUL can never appear in real source, so a masked offset is unambiguous. */ +const MASKED = '\u0000' + +/** + * Marks every comment and string-literal character, preserving offsets, so a match can be tested + * for whether it sits in code or in data. Interpolations stay code: `${require('fs')}` is real. + */ +function maskLexicalSpans(code: string): string { + const chars = code.split('') + const mask = (from: number, to: number): void => { + for (let at = from; at < Math.min(to, chars.length); at += 1) { + chars[at] = MASKED + } + } + const interpolations: number[] = [] // brace depth inside each enclosing `${}` + let inTemplate = false + // A `/` here starts a regex, not a division; anything else makes it an operator. + let regexAllowed = true + let index = 0 + while (index < code.length) { + const char = code[index] + const pair = code.slice(index, index + 2) + if (inTemplate) { + if (char === '`') { + inTemplate = false + regexAllowed = false + index += 1 + } else if (pair === '${') { + interpolations.push(0) + inTemplate = false + index += 2 + } else { + const width = char === '\\' ? 2 : 1 + mask(index, index + width) + index += width + } + continue + } + if (pair === '//' || pair === '/*') { + const close = pair === '//' ? code.indexOf('\n', index) : code.indexOf('*/', index + 2) + const end = close === -1 ? code.length : pair === '//' ? close : close + 2 + mask(index, end) + index = end + continue + } + if (char === "'" || char === '"') { + let scan = index + 1 + // A raw newline ends an unterminated literal; nothing after it is still that string. + while (scan < code.length && code[scan] !== char && code[scan] !== '\n') { + scan += code[scan] === '\\' ? 2 : 1 + } + mask(index, scan + 1) + index = scan + 1 + regexAllowed = false + continue + } + // Why: an unpaired quote inside a regex would otherwise open a string over real code. + if (char === '/' && regexAllowed) { + let scan = index + 1 + let inClass = false + while (scan < code.length && code[scan] !== '\n' && (inClass || code[scan] !== '/')) { + if (code[scan] === '\\') { + scan += 1 + } else if (code[scan] === '[' || code[scan] === ']') { + inClass = code[scan] === '[' + } + scan += 1 + } + mask(index, scan + 1) + index = scan + 1 + regexAllowed = false + continue + } + if (!/\s/.test(char)) { + regexAllowed = !/[\w$)\]]/.test(char) + } + if (char === '`') { + inTemplate = true + } else if (interpolations.length > 0 && (char === '{' || char === '}')) { + const last = interpolations.length - 1 + if (char === '{') { + interpolations[last] += 1 + } else if (interpolations[last] > 0) { + interpolations[last] -= 1 + } else { + interpolations.pop() + inTemplate = true + } + } + index += 1 + } + return chars.join('') +} + export function findPreloadSandboxViolations(code: string): PreloadSandboxViolation[] { const violations = new Map() + // Why: the patterns still run on real source, so specifiers survive; the mask only vetoes + // matches whose module keyword is inside a comment or string. + const masked = maskLexicalSpans(code) + const isData = (match: RegExpExecArray | RegExpMatchArray): boolean => { + const keyword = Math.max(match[0].search(/import|export|require/), 0) + return masked[(match.index ?? 0) + keyword] === MASKED + } for (const pattern of [LITERAL_MODULE_CALL_RE, STATIC_MODULE_SOURCE_RE]) { pattern.lastIndex = 0 for (const match of code.matchAll(pattern)) { const specifier = match[2] - if (specifier !== undefined) { + if (specifier !== undefined && !isData(match)) { collect(violations, classifySpecifier(specifier)) } } } NONLITERAL_MODULE_CALL_RE.lastIndex = 0 for (const match of code.matchAll(NONLITERAL_MODULE_CALL_RE)) { + if (isData(match)) { + continue + } collect(violations, { kind: 'nonliteral-module-request', detail: code