Compare commits

..
Author SHA1 Message Date
Abdul WahabandCursor daae1d4117 Fix: reject root-relative .. segments and warn per scan
Dot-segment hrefs like /../outside.css could leave the project, and a process-wide warning set hid missing-sheet notices on later detectHtml calls.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:47:54 +05:00
Abdul WahabandCursor 2b88aa5231 Fix: resolve root-relative linked stylesheets in static detect (#652)
Root-relative hrefs like /static/app.css were treated as OS-absolute and silently dropped, hiding contrast findings.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:34:55 +05:00
4 changed files with 116 additions and 283 deletions
+2 -64
View File
@@ -162,68 +162,7 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
async function detectUrl(url, options = {}) {
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -299,7 +238,6 @@ async function detectUrl(rawUrl, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -431,4 +369,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+38 -5
View File
@@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) {
};
}
function resolveLinkedCssPath(fileDir, href) {
const stripped = href.split(/[?#]/)[0];
const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//');
if (!rootRelative) return path.resolve(fileDir, stripped);
// 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);
if (parent === dir) break; // never use the filesystem root as document root
try {
const candidate = path.join(dir, rel);
if (fs.statSync(candidate).isFile()) return candidate;
} catch { /* missing or unreadable candidate */ }
// Stop at the project root so a coincidental ~/static/app.css cannot win.
try {
if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) break;
} catch { /* unreadable marker */ }
dir = parent;
}
return path.join(fileDir, rel);
}
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));
}
@@ -974,10 +1000,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
// literal path with the query in it; a versioned link otherwise made the
// whole stylesheet invisible to every element-level check.
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
// invisible to every element-level check.
const cssPath = resolveLinkedCssPath(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
@@ -987,7 +1013,14 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
} catch {
if (!warnedMissingStylesheets.has(cssPath)) {
warnedMissingStylesheets.add(cssPath);
process.stderr.write(
`impeccable detect: could not read linked stylesheet ${href} (resolved to ${cssPath}); color and custom-property rules will be incomplete\n`
);
}
}
}
return styleTexts.join('\n');
}
+75
View File
@@ -1321,6 +1321,81 @@ describe('detectHtml — static HTML/CSS engine', () => {
expect(findingIds(f)).toContain('side-tab');
});
test('resolves root-relative linked stylesheets with cache-busting query', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/static/app.css?v=3">
</head><body><div class="card">Card</div></body></html>`,
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
}, async ({ file }) => {
const f = await detectHtml(file);
expect(findingIds(f)).toContain('side-tab');
});
});
test('resolves root-relative linked stylesheets from nested pages via ancestor walk', async () => {
await withStaticFixture({
'pages/about.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/static/app.css">
</head><body><div class="card">Card</div></body></html>`,
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
}, async ({ dir }) => {
const f = await detectHtml(path.join(dir, 'pages', 'about.html'));
expect(findingIds(f)).toContain('side-tab');
});
});
test('does not resolve root-relative sheets above the project root', async () => {
await withStaticFixture({
'project/package.json': '{}',
'project/index.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/static/app.css">
</head><body><div class="card">Card</div></body></html>`,
'static/app.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('does not follow root-relative .. segments out of the page directory', async () => {
await withStaticFixture({
'project/package.json': '{}',
'project/index.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/../outside.css">
</head><body><div class="card">Card</div></body></html>`,
'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);
process.stderr.write = (chunk, ...args) => {
writes.push(String(chunk));
return origWrite(chunk, ...args);
};
try {
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/missing/app.css">
</head><body><div>Page</div></body></html>`,
}, async ({ file, dir }) => {
await detectHtml(file);
await detectHtml(file);
const msg = writes.join('');
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 {
process.stderr.write = origWrite;
}
});
test('gradient-text: a style="" attribute alone carries the page-level flag', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head><title>t</title></head><body>
+1 -214
View File
@@ -1,6 +1,5 @@
import { describe, test, expect, afterEach } from 'bun:test';
import http from 'node:http';
import { launchBrowser, detectUrl, splitScanUrl } from '../cli/engine/engines/browser/detect-url.mjs';
import { launchBrowser } from '../cli/engine/engines/browser/detect-url.mjs';
// launchBrowser prefers the system-installed Chrome on Windows to dodge the
// bundled-Chrome GPU crash-loop (issue #372), and keeps the pinned bundled
@@ -80,215 +79,3 @@ describe('launchBrowser', () => {
expect(p.calls.every(c => c.channel === undefined)).toBe(true);
});
});
describe('splitScanUrl', () => {
test('strips http(s) userinfo and returns credentials', () => {
expect(splitScanUrl('https://user:pass@example.com')).toEqual({
href: 'https://example.com/',
credentials: { username: 'user', password: 'pass' },
});
expect(splitScanUrl('https://user:p%40ss@example.com/path?q=1')).toEqual({
href: 'https://example.com/path?q=1',
credentials: { username: 'user', password: 'p@ss' },
});
expect(splitScanUrl('https://user@example.com')).toEqual({
href: 'https://example.com/',
credentials: { username: 'user', password: '' },
});
expect(splitScanUrl('http://:secret@host.com/')).toEqual({
href: 'http://host.com/',
credentials: { username: '', password: 'secret' },
});
});
test('preserves original string when no userinfo', () => {
expect(splitScanUrl('https://example.com')).toEqual({
href: 'https://example.com',
credentials: null,
});
expect(splitScanUrl('https://example.com/path?email=a@b.com')).toEqual({
href: 'https://example.com/path?email=a@b.com',
credentials: null,
});
});
test('handles IPv6 and non-http(s) URLs', () => {
expect(splitScanUrl('https://user:pass@[::1]:8080/x')).toEqual({
href: 'https://[::1]:8080/x',
credentials: { username: 'user', password: 'pass' },
});
expect(splitScanUrl('file:///tmp/a.html')).toEqual({
href: 'file:///tmp/a.html',
credentials: null,
});
});
test('returns original string for invalid URLs', () => {
expect(splitScanUrl('not a url')).toEqual({
href: 'not a url',
credentials: null,
});
});
});
function makeFakeBrowser() {
const calls = { intercept: false, requestHandler: null, authenticate: [], goto: [] };
const page = {
on(event, handler) {
if (event === 'request') calls.requestHandler = handler;
},
async setViewport() {},
async setRequestInterception() { calls.intercept = true; },
async authenticate(creds) { calls.authenticate.push(creds); },
async goto(url, opts) { calls.goto.push({ url, opts }); },
async evaluate(fn) {
if (typeof fn === 'function' && fn.toString().includes('impeccableDetect')) {
return [{ findings: [{ type: 'low-contrast', detail: 'x', ignoreValue: '', severity: '' }] }];
}
return [];
},
async close() {},
};
return {
calls,
browser: {
async newPage() { return page; },
},
};
}
function fakeRequest(url, calls) {
return {
url: () => url,
headers: () => ({ accept: 'text/html' }),
continue(overrides) {
calls.continues.push({ url, overrides });
return Promise.resolve();
},
};
}
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve(`http://127.0.0.1:${server.address().port}/`);
});
});
}
describe('detectUrl credential redaction', () => {
test('scopes Authorization to the scan origin and redacts findings', async () => {
const { calls, browser } = makeFakeBrowser();
calls.continues = [];
const findings = await detectUrl('https://user:p%40ss@example.com/path', {
browser,
visualContrast: false,
contentHidden: false,
});
expect(calls.authenticate).toEqual([]);
expect(calls.intercept).toBe(true);
expect(typeof calls.requestHandler).toBe('function');
expect(calls.goto).toHaveLength(1);
expect(calls.goto[0].url).toBe('https://example.com/path');
const expected = `Basic ${Buffer.from('user:p@ss').toString('base64')}`;
await calls.requestHandler(fakeRequest('https://example.com/path', calls));
await calls.requestHandler(fakeRequest('https://evil.example/steal', calls));
expect(calls.continues[0].overrides.headers.authorization).toBe(expected);
expect(calls.continues[1].overrides).toBeUndefined();
expect(findings.length).toBeGreaterThan(0);
for (const f of findings) {
expect(f.file).toBe('https://example.com/path');
}
});
test('does not intercept when URL has no userinfo', async () => {
const { calls, browser } = makeFakeBrowser();
const url = 'https://example.com/path';
const findings = await detectUrl(url, {
browser,
visualContrast: false,
contentHidden: false,
});
expect(calls.authenticate).toEqual([]);
expect(calls.intercept).toBe(false);
expect(calls.requestHandler).toBe(null);
expect(findings.length).toBeGreaterThan(0);
for (const f of findings) {
expect(f.file).toBe(url);
}
});
});
describe('detectUrl origin-scoped basic auth', () => {
test('does not send URL credentials to a cross-origin redirect that challenges', async () => {
const user = 'qa-scanner';
const pass = 'Hunter2-657-SHOULD-NOT-LEAK';
const expected = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
const seenOnB = [];
const serverB = http.createServer((req, res) => {
seenOnB.push(req.headers.authorization || '');
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="b"' });
res.end('b');
});
const urlB = await listen(serverB);
const serverA = http.createServer((req, res) => {
res.writeHead(302, { Location: urlB });
res.end();
});
const urlA = await listen(serverA);
try {
try {
await detectUrl(urlA.replace('http://', `http://${user}:${pass}@`), {
visualContrast: false,
contentHidden: false,
waitUntil: 'domcontentloaded',
});
} catch {
// B's 401 may fail navigation once credentials are withheld.
}
expect(seenOnB.includes(expected)).toBe(false);
} finally {
await Promise.all([
new Promise((resolve) => serverA.close(resolve)),
new Promise((resolve) => serverB.close(resolve)),
]);
}
}, { timeout: 30000 });
test('still authenticates the original scan origin', async () => {
const user = 'qa-scanner';
const pass = 'Hunter2-657-SHOULD-NOT-LEAK';
const expected = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
const seen = [];
const server = http.createServer((req, res) => {
seen.push(req.headers.authorization || '');
if (req.headers.authorization !== expected) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="a"' });
res.end('no');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<!doctype html><html><body><h1>ok</h1></body></html>');
});
const origin = await listen(server);
try {
await detectUrl(origin.replace('http://', `http://${user}:${pass}@`), {
visualContrast: false,
contentHidden: false,
waitUntil: 'domcontentloaded',
});
expect(seen.includes(expected)).toBe(true);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}, { timeout: 30000 });
});