mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(deploy): bundle and rewrite assets referenced from inline <style> blocks and style="" attributes (#314)
* fix(deploy): bundle and rewrite assets referenced from inline <style> blocks and style="" attributes buildDeployFileSet only scanned HTML attributes (src/poster/href/srcset) and external .css files for asset references. url() and @import refs inside inline <style> blocks and style="" attributes were silently skipped, so deployed pages were missing their @font-face sources, hero backgrounds, and any image referenced from inline CSS, even though the files existed in the project. Add two helpers in deploy.ts: - extractInlineCssReferences(html) collects url()/@import refs from <style> blocks and style attributes, reusing extractCssReferences and honoring htmlRawTextRanges so style-like text inside scripts and HTML comments is ignored. - rewriteCssReferences(css, baseDir) rewrites url() and bare @import paths the same way rewriteHtmlReference rewrites src/href, so inline CSS keeps working when the entry HTML moves up to the deploy root. Wire both into the existing pipeline: - buildDeployFileSet pushes inline-style refs onto the pending queue alongside HTML and supportingFiles refs. - rewriteEntryHtmlReferences first rewrites <style> block contents, then re-derives htmlRawTextRanges so the tag-attribute pass still skips raw-text regions whose offsets shifted. - rewriteHtmlAttributes treats style="" the same way as srcset, routing through rewriteCssReferences while preserving the original quoting. Tests cover extraction, bundling end-to-end (including @font-face, @import, and subdirectory entries), rewriting of CSS content with remote/data/absolute refs left intact, and the missing-file error path. * fix(deploy): harden inline-CSS pipeline per review (P1 script-literal masking, P2 ReDoS, SVG coverage) Address review feedback on the inline-CSS deploy pipeline: P1 (Codex bot): rewriteEntryHtmlReferences now computes htmlRawTextRanges on the input HTML before the <style> block pre-pass, and skips any <style>...</style> 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: - <style> inside <svg>: extraction and subdir rewriting both work. - <style>-like text inside <script> literals: round-trips byte-for-byte. - <style>-like text inside HTML comments: untouched. - Pathological unclosed url(...... runs in <500ms for 100k input. All 36 deploy tests pass (262 across the daemon package). --------- Co-authored-by: Nagendhra <nagendhra405@gmail.com>
This commit is contained in:
co-authored by
Nagendhra
parent
816cc1d135
commit
0943cb1452
@@ -105,6 +105,13 @@ export async function buildDeployFileSet(projectsRoot, projectId, entryName, opt
|
||||
base: entryBase,
|
||||
}));
|
||||
|
||||
// Inline `<style>` blocks and `style="..."` attributes can reference
|
||||
// background images, custom fonts, and stylesheets via @import. They
|
||||
// are resolved relative to the entry HTML, same as src/href.
|
||||
for (const ref of extractInlineCssReferences(html)) {
|
||||
pending.push({ ref, base: entryBase });
|
||||
}
|
||||
|
||||
for (const manifestRef of entry.artifactManifest?.supportingFiles ?? []) {
|
||||
pending.push({ ref: manifestRef, base: entryBase });
|
||||
}
|
||||
@@ -232,16 +239,71 @@ export function extractHtmlReferences(html) {
|
||||
return refs;
|
||||
}
|
||||
|
||||
// Character classes scope the lazy match so unclosed url(((( or
|
||||
// `@import "foo` cannot trigger O(n^2) regex backtracking on
|
||||
// attacker-controlled CSS. The tradeoff is that quoted urls
|
||||
// containing literal `)` characters must be percent-encoded; CSS
|
||||
// authors are already expected to do this in practice.
|
||||
const CSS_URL_REGEX = /url\(\s*(['"]?)([^)]*?)\1\s*\)/gi;
|
||||
const CSS_IMPORT_REGEX = /@import\s+(?:url\(\s*)?(['"])([^'"]*?)\1/gi;
|
||||
|
||||
export function extractCssReferences(css) {
|
||||
const refs = [];
|
||||
const urlRe = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
|
||||
const urlRe = new RegExp(CSS_URL_REGEX.source, CSS_URL_REGEX.flags);
|
||||
let match;
|
||||
while ((match = urlRe.exec(css))) refs.push(match[2]);
|
||||
const importRe = /@import\s+(?:url\(\s*)?(['"])(.*?)\1/gi;
|
||||
const importRe = new RegExp(CSS_IMPORT_REGEX.source, CSS_IMPORT_REGEX.flags);
|
||||
while ((match = importRe.exec(css))) refs.push(match[2]);
|
||||
return refs;
|
||||
}
|
||||
|
||||
// Collect url() / @import references from inline `<style>` blocks and
|
||||
// `style="..."` attributes. These bypass the external-stylesheet path
|
||||
// (link rel=stylesheet -> .css file -> extractCssReferences) but still
|
||||
// pull in real assets, e.g. background images and @font-face sources.
|
||||
//
|
||||
// Style-like text that lives inside `<script>` string literals or HTML
|
||||
// comments is intentionally skipped, mirroring how extractHtmlReferences
|
||||
// treats those raw-text regions.
|
||||
export function extractInlineCssReferences(html) {
|
||||
const source = String(html);
|
||||
const refs = [];
|
||||
const skipRanges = htmlRawTextRanges(source);
|
||||
|
||||
const styleBlockRe = /<style\b[^<>]*>([\s\S]*?)<\/style\s*>/gi;
|
||||
let block;
|
||||
while ((block = styleBlockRe.exec(source))) {
|
||||
if (isOffsetInRanges(block.index, skipRanges)) continue;
|
||||
refs.push(...extractCssReferences(block[1]));
|
||||
}
|
||||
|
||||
for (const tag of parseHtmlTags(source)) {
|
||||
const attrs = parseHtmlAttributes(tag.attrs);
|
||||
const style = attrs.get('style');
|
||||
if (style) refs.push(...extractCssReferences(style));
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
// Rewrite url() / @import references inside a CSS string so that paths
|
||||
// resolved relative to `baseDir` survive the entry-HTML being moved to
|
||||
// the deploy root. Mirrors `rewriteHtmlReference` for HTML attributes.
|
||||
// Uses the same hardened character classes as `extractCssReferences` so
|
||||
// extract and rewrite see the same set of references.
|
||||
export function rewriteCssReferences(css, baseDir) {
|
||||
return String(css)
|
||||
.replace(CSS_URL_REGEX, (match, quote, value) => {
|
||||
if (!value) return match;
|
||||
const rewritten = rewriteHtmlReference(value, baseDir);
|
||||
return `url(${quote}${rewritten}${quote})`;
|
||||
})
|
||||
.replace(/(@import\s+)(['"])([^'"]*?)\2/gi, (_full, prefix, quote, value) => {
|
||||
const rewritten = rewriteHtmlReference(value, baseDir);
|
||||
return `${prefix}${quote}${rewritten}${quote}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveReferencedPath(raw, baseDir) {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim();
|
||||
@@ -256,8 +318,26 @@ export function resolveReferencedPath(raw, baseDir) {
|
||||
}
|
||||
|
||||
export function rewriteEntryHtmlReferences(html, baseDir) {
|
||||
const rawTextRanges = htmlRawTextRanges(html);
|
||||
return String(html).replace(/<([A-Za-z][A-Za-z0-9:-]*)([^<>]*?)>/g, (tag, rawName, rawAttrs, offset) => {
|
||||
const source = String(html);
|
||||
// Compute raw-text ranges against the input first so the style-block
|
||||
// pre-pass can skip `<style>...</style>` text that lives inside a
|
||||
// `<script>` string literal or an HTML comment. Without this gate, a
|
||||
// template like `const tpl = '<style>...url("foo")...</style>'` would
|
||||
// get mutated, changing runtime JS behavior.
|
||||
const inputRawTextRanges = htmlRawTextRanges(source);
|
||||
const styleRewritten = source.replace(
|
||||
/(<style\b[^<>]*>)([\s\S]*?)(<\/style\s*>)/gi,
|
||||
(full, openTag, content, closeTag, offset) => {
|
||||
if (isOffsetInRanges(offset, inputRawTextRanges)) return full;
|
||||
return `${openTag}${rewriteCssReferences(content, baseDir)}${closeTag}`;
|
||||
},
|
||||
);
|
||||
// Re-derive raw-text ranges against the post-style HTML: rewriting can
|
||||
// shift offsets, and the tag-attribute pass below skips raw-text
|
||||
// regions by absolute offset. Two scans are intentional, deploy is
|
||||
// not a hot path and the cost is linear in document size.
|
||||
const rawTextRanges = htmlRawTextRanges(styleRewritten);
|
||||
return styleRewritten.replace(/<([A-Za-z][A-Za-z0-9:-]*)([^<>]*?)>/g, (tag, rawName, rawAttrs, offset) => {
|
||||
if (isOffsetInRanges(offset, rawTextRanges)) return tag;
|
||||
const tagName = String(rawName).toLowerCase();
|
||||
const attrs = parseHtmlAttributes(rawAttrs);
|
||||
@@ -372,15 +452,22 @@ function rewriteHtmlAttributes(rawAttrs, tagName, attrs, baseDir) {
|
||||
/([^\s"'<>/=]+)(\s*=\s*)("([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g,
|
||||
(full, rawName, equals, rawValue, doubleQuoted, singleQuoted, unquoted) => {
|
||||
const name = String(rawName).toLowerCase();
|
||||
if (name !== 'src' && name !== 'poster' && name !== 'srcset' && name !== 'href') {
|
||||
if (
|
||||
name !== 'src' &&
|
||||
name !== 'poster' &&
|
||||
name !== 'srcset' &&
|
||||
name !== 'href' &&
|
||||
name !== 'style'
|
||||
) {
|
||||
return full;
|
||||
}
|
||||
if (name === 'href' && !shouldRewriteHref) return full;
|
||||
|
||||
const value = doubleQuoted ?? singleQuoted ?? unquoted ?? '';
|
||||
const nextValue = name === 'srcset'
|
||||
? rewriteSrcset(value, baseDir)
|
||||
: rewriteHtmlReference(value, baseDir);
|
||||
let nextValue;
|
||||
if (name === 'srcset') nextValue = rewriteSrcset(value, baseDir);
|
||||
else if (name === 'style') nextValue = rewriteCssReferences(value, baseDir);
|
||||
else nextValue = rewriteHtmlReference(value, baseDir);
|
||||
if (doubleQuoted !== undefined) return `${rawName}${equals}"${nextValue}"`;
|
||||
if (singleQuoted !== undefined) return `${rawName}${equals}'${nextValue}'`;
|
||||
return `${rawName}${equals}${nextValue}`;
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
deploymentUrlCandidates,
|
||||
extractCssReferences,
|
||||
extractHtmlReferences,
|
||||
extractInlineCssReferences,
|
||||
injectDeployHookScript,
|
||||
isVercelProtectedResponse,
|
||||
normalizeDeployHookScriptUrl,
|
||||
resolveReferencedPath,
|
||||
rewriteCssReferences,
|
||||
rewriteEntryHtmlReferences,
|
||||
waitForReachableDeploymentUrl,
|
||||
} from '../src/deploy.js';
|
||||
@@ -243,6 +245,188 @@ describe('deploy file set', () => {
|
||||
'https://cdn.example.com/hook.js',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts url() and @import refs from inline <style> blocks', () => {
|
||||
const refs = extractInlineCssReferences(
|
||||
'<!doctype html><style>@import "theme.css";body{background:url("bg.png")}</style>',
|
||||
);
|
||||
expect(refs.sort()).toEqual(['bg.png', 'theme.css']);
|
||||
});
|
||||
|
||||
it('extracts url() refs from style="" attributes', () => {
|
||||
const refs = extractInlineCssReferences(
|
||||
"<div style=\"background:url('bg.png')\"></div><span style=\"--bg:url(/abs.png)\"></span>",
|
||||
);
|
||||
expect(refs.sort()).toEqual(['/abs.png', 'bg.png']);
|
||||
});
|
||||
|
||||
it('skips style-like text inside scripts and comments', () => {
|
||||
const refs = extractInlineCssReferences(
|
||||
'<!-- <style>body{background:url("ghost.png")}</style> -->' +
|
||||
'<script>const css = \'<style>body{background:url("missing.png")}</style>\';</script>',
|
||||
);
|
||||
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 <style> blocks', async () => {
|
||||
const { projectsRoot, projectId, dir } = await setupProject();
|
||||
await mkdir(path.join(dir, 'assets'));
|
||||
await mkdir(path.join(dir, 'fonts'));
|
||||
await writeFile(
|
||||
path.join(dir, 'index.html'),
|
||||
'<!doctype html><style>' +
|
||||
'@import "theme.css";' +
|
||||
"body{background:url('assets/bg.png')}" +
|
||||
'@font-face{font-family:Custom;src:url("fonts/custom.woff2") format("woff2");}' +
|
||||
'</style>',
|
||||
);
|
||||
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'),
|
||||
'<!doctype html><div style="background:url(\'assets/hero.png\')">x</div>',
|
||||
);
|
||||
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 <style> url() refs when entry is in a subdirectory', async () => {
|
||||
const { projectsRoot, projectId, dir } = await setupProject();
|
||||
await mkdir(path.join(dir, 'sub', 'assets'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dir, 'sub', 'page.html'),
|
||||
'<!doctype html><style>body{background:url("assets/bg.png")}</style>',
|
||||
);
|
||||
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'),
|
||||
"<!doctype html><div style=\"background:url('hero.png')\">x</div>",
|
||||
);
|
||||
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 <style> assets that are missing on disk', async () => {
|
||||
const { projectsRoot, projectId, dir } = await setupProject();
|
||||
await writeFile(
|
||||
path.join(dir, 'index.html'),
|
||||
'<!doctype html><style>body{background:url("assets/missing.png")}</style>',
|
||||
);
|
||||
|
||||
await expect(
|
||||
buildDeployFileSet(projectsRoot, projectId, 'index.html'),
|
||||
).rejects.toMatchObject({
|
||||
details: { missing: ['assets/missing.png'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts and rewrites url() refs from <style> inside <svg>', async () => {
|
||||
const { projectsRoot, projectId, dir } = await setupProject();
|
||||
await mkdir(path.join(dir, 'sub', 'assets'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dir, 'sub', 'page.html'),
|
||||
'<!doctype html><svg><style>circle{fill:url("assets/icon.svg")}</style></svg>',
|
||||
);
|
||||
await writeFile(path.join(dir, 'sub', 'assets', 'icon.svg'), '<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 <style>-like text inside <script> string literals', async () => {
|
||||
const { projectsRoot, projectId, dir } = await setupProject();
|
||||
await mkdir(path.join(dir, 'sub'), { recursive: true });
|
||||
const html =
|
||||
'<!doctype html><script>const tpl = \'<style>body{background:url("assets/bg.png")}</style>\';</script>';
|
||||
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 <style> lives inside a JS string literal, so it must not
|
||||
// be processed as inline CSS: no asset is bundled and the script
|
||||
// body is preserved byte-for-byte.
|
||||
expect(files.map((f) => f.file)).toEqual(['index.html']);
|
||||
expect(index?.data.toString('utf8')).toContain(
|
||||
"const tpl = '<style>body{background:url(\"assets/bg.png\")}</style>';",
|
||||
);
|
||||
});
|
||||
|
||||
it('does not rewrite <style>-like text inside HTML comments', () => {
|
||||
const html =
|
||||
'<!doctype html><!-- <style>body{background:url("ghost.png")}</style> --><h1>x</h1>';
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user