From 0943cb1452f5d74577aa2cefe635ba01cbeb71a2 Mon Sep 17 00:00:00 2001 From: Nagendhra Madishetti Date: Sat, 2 May 2026 20:50:21 -0400 Subject: [PATCH] fix(deploy): bundle and rewrite assets referenced from inline match whose offset falls inside a script string literal or HTML comment. Previously the rewriter mutated those textual fragments, which could change runtime JS behavior on subdirectory deploys. Mirrors the gate already used by extractInlineCssReferences. P2 (lefarcen): tighten the CSS url() and @import regex character classes from .*? to [^)]*? and [^'"]*? respectively. Quoted urls containing literal `)` are now expected to be percent-encoded, which matches CSS authoring conventions and removes the O(n^2) backtracking risk on attacker-controlled CSS. The hardened patterns are shared between extractCssReferences and rewriteCssReferences via CSS_URL_REGEX / CSS_IMPORT_REGEX so the two paths cannot diverge. P3 (lefarcen): add an explicit comment on the rewriteEntryHtmlReferences double-pass so the perf tradeoff is visible inline. New tests: - ` text that lives inside a + // `', + ); + expect(refs).toEqual([]); + }); + + it('rewrites url() and @import refs in css content relative to baseDir', () => { + expect( + rewriteCssReferences( + '@import "theme.css";body{background:url("bg.png")}', + 'sub', + ), + ).toBe('@import "sub/theme.css";body{background:url("sub/bg.png")}'); + }); + + it('keeps remote, data, and absolute css refs intact when rewriting', () => { + expect( + rewriteCssReferences( + 'body{background:url("https://cdn.test/a.png");--data:url(data:image/png,abc);--root:url("/abs.png")}', + 'sub', + ), + ).toBe( + 'body{background:url("https://cdn.test/a.png");--data:url(data:image/png,abc);--root:url("/abs.png")}', + ); + }); + + it('bundles assets referenced from inline ', + ); + await writeFile(path.join(dir, 'theme.css'), 'body{color:red}'); + await writeFile(path.join(dir, 'assets', 'bg.png'), 'bg'); + await writeFile(path.join(dir, 'fonts', 'custom.woff2'), 'font'); + + const files = await buildDeployFileSet(projectsRoot, projectId, 'index.html'); + + expect(files.map((f) => f.file).sort()).toEqual([ + 'assets/bg.png', + 'fonts/custom.woff2', + 'index.html', + 'theme.css', + ]); + }); + + it('bundles assets referenced from style="" attributes', async () => { + const { projectsRoot, projectId, dir } = await setupProject(); + await mkdir(path.join(dir, 'assets')); + await writeFile( + path.join(dir, 'index.html'), + '
x
', + ); + await writeFile(path.join(dir, 'assets', 'hero.png'), 'hero'); + + const files = await buildDeployFileSet(projectsRoot, projectId, 'index.html'); + + expect(files.map((f) => f.file).sort()).toEqual(['assets/hero.png', 'index.html']); + }); + + it('rewrites inline ', + ); + await writeFile(path.join(dir, 'sub', 'assets', 'bg.png'), 'bg'); + + const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html'); + const index = files.find((f) => f.file === 'index.html'); + + expect(files.map((f) => f.file).sort()).toEqual(['index.html', 'sub/assets/bg.png']); + expect(index?.data.toString('utf8')).toContain('url("sub/assets/bg.png")'); + }); + + it('rewrites style="" url() refs when entry is in a subdirectory', async () => { + const { projectsRoot, projectId, dir } = await setupProject(); + await mkdir(path.join(dir, 'sub'), { recursive: true }); + await writeFile( + path.join(dir, 'sub', 'page.html'), + "
x
", + ); + await writeFile(path.join(dir, 'sub', 'hero.png'), 'hero'); + + const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html'); + const index = files.find((f) => f.file === 'index.html'); + + expect(files.map((f) => f.file).sort()).toEqual(['index.html', 'sub/hero.png']); + expect(index?.data.toString('utf8')).toContain("url('sub/hero.png')"); + }); + + it('reports inline ', + ); + + await expect( + buildDeployFileSet(projectsRoot, projectId, 'index.html'), + ).rejects.toMatchObject({ + details: { missing: ['assets/missing.png'] }, + }); + }); + + it('extracts and rewrites url() refs from ', + ); + await writeFile(path.join(dir, 'sub', 'assets', 'icon.svg'), ''); + + const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html'); + const index = files.find((f) => f.file === 'index.html'); + + expect(files.map((f) => f.file).sort()).toEqual(['index.html', 'sub/assets/icon.svg']); + expect(index?.data.toString('utf8')).toContain('url("sub/assets/icon.svg")'); + }); + + it('does not rewrite \';'; + await writeFile(path.join(dir, 'sub', 'page.html'), html); + + const files = await buildDeployFileSet(projectsRoot, projectId, 'sub/page.html'); + const index = files.find((f) => f.file === 'index.html'); + + // The fake ';", + ); + }); + + it('does not rewrite -->

x

'; + expect(rewriteEntryHtmlReferences(html, 'sub')).toBe(html); + }); + + it('runs in linear time on pathological unclosed url(', () => { + const huge = '('.repeat(100_000); + const input = `body{background:url${huge}}`; + const startExtract = Date.now(); + const refs = extractCssReferences(input); + expect(Date.now() - startExtract).toBeLessThan(500); + expect(refs).toEqual([]); + + const startRewrite = Date.now(); + expect(rewriteCssReferences(input, 'sub')).toBe(input); + expect(Date.now() - startRewrite).toBeLessThan(500); + }); }); describe('deployment link readiness', () => {