diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index be46ceb57..cc36ac9b5 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -964,13 +964,14 @@ function buildStaticWindow(staticDoc) { }; } -const warnedMissingStylesheets = new Set(); - function resolveLinkedCssPath(fileDir, href) { const stripped = href.split(/[?#]/)[0]; const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//'); if (!rootRelative) return path.resolve(fileDir, stripped); - const rel = stripped.replace(/^\/+/, ''); + // Drop "." and reject ".." so /../outside.css cannot walk out of dir. + const segments = stripped.replace(/^\/+/, '').split(/[/\\]/).filter(p => p && p !== '.'); + if (segments.some(p => p === '..')) return path.join(fileDir, segments.filter(p => p !== '..').join(path.sep)); + const rel = segments.join(path.sep); let dir = fileDir; for (;;) { const parent = path.dirname(dir); @@ -990,6 +991,7 @@ function resolveLinkedCssPath(fileDir, href) { function collectStaticCssText(root, fileDir, profile, filePath, modules) { const styleTexts = []; + const warnedMissingStylesheets = new Set(); for (const styleEl of modules.selectAll('style', root.children || [])) { styleTexts.push(modules.domutils.textContent(styleEl)); } diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index eba5efb4d..66f6f96d7 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -1358,6 +1358,19 @@ describe('detectHtml — static HTML/CSS engine', () => { }); }); + test('does not follow root-relative .. segments out of the page directory', async () => { + await withStaticFixture({ + 'project/package.json': '{}', + 'project/index.html': ` + +
Card
`, + 'outside.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }', + }, async ({ dir }) => { + const f = await detectHtml(path.join(dir, 'project', 'index.html')); + expect(findingIds(f)).not.toContain('side-tab'); + }); + }); + test('warns when a linked stylesheet cannot be read', async () => { const writes = []; const origWrite = process.stderr.write.bind(process.stderr); @@ -1371,9 +1384,11 @@ describe('detectHtml — static HTML/CSS engine', () => {
Page
`, }, async ({ file, dir }) => { + await detectHtml(file); await detectHtml(file); const msg = writes.join(''); - expect(msg).toContain('could not read linked stylesheet /missing/app.css'); + const hits = msg.split('could not read linked stylesheet /missing/app.css').length - 1; + expect(hits).toBe(2); expect(msg).toContain(`resolved to ${path.join(dir, 'missing', 'app.css')}`); }); } finally {