mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165f94e939 | ||
|
|
c9c6928be6 |
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
|
||||
// Puppeteer detection (for URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function detectUrl(url, options = {}) {
|
||||
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);
|
||||
const profile = options?.profile;
|
||||
const waitUntil = options?.waitUntil || 'networkidle0';
|
||||
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
|
||||
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
|
||||
ruleId: 'set-viewport',
|
||||
target: url,
|
||||
}, () => page.setViewport(viewport));
|
||||
await applyOriginScopedAuth(page, url, credentials);
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { launchBrowser } from '../cli/engine/engines/browser/detect-url.mjs';
|
||||
import http from 'node:http';
|
||||
import { launchBrowser, detectUrl, splitScanUrl } 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
|
||||
@@ -79,3 +80,215 @@ 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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user