Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions backend/controllers/scanController.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* See docs/guides/axecore-integration.md for the bug pattern this
* sidesteps.
*/
const { safeUrl } = require('../services/redactUrl');

class ScanController {
/**
* @param {object} deps
Expand Down Expand Up @@ -46,7 +48,7 @@ class ScanController {
return res.status(400).json({ error: guard.reason });
}
}
console.log(`Received scan request for URL: ${url}`);
console.log(`Received scan request for URL: ${safeUrl(url)}`);
try {
const result = await this.scanRunner.run(url);

Expand Down Expand Up @@ -119,7 +121,7 @@ class ScanController {
return res.status(400).json({ error: guard.reason });
}
}
console.log(`Received request for scan results of URL: ${url}`);
console.log(`Received request for scan results of URL: ${safeUrl(url)}`);
try {
const result = await this.scanRunner.getResults(url);
if (result === null) {
Expand Down
32 changes: 32 additions & 0 deletions backend/services/redactUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Log-safe URLs. A scanned reset link or signed URL carries its token in the
* query string (or fragment), so logs keep the origin and path only. Pure — no
* side effects.
*/

/**
* Origin + path of a URL, with query, fragment and any `user:pass@` dropped.
* @param {unknown} url
* @returns {string}
*/
function safeUrl(url) {
try {
const parsed = new URL(String(url));
return parsed.origin + parsed.pathname;
} catch {
// Unparseable input still gets its query/fragment cut.
return String(url ?? '').split(/[?#]/)[0];
}
}

/**
* Apply `safeUrl` to every http(s) URL inside free text, e.g. a Puppeteer error
* like `net::ERR_NAME_NOT_RESOLVED at https://x/?token=…`.
* @param {unknown} text
* @returns {string}
*/
function scrubUrls(text) {
return String(text ?? '').replace(/https?:\/\/\S+/gi, (match) => safeUrl(match));
}

module.exports = { safeUrl, scrubUrls };
8 changes: 7 additions & 1 deletion backend/services/scanRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const fs = require('node:fs');

const defaultValidate = require('./ssrfGuard').validate;
const { transform: defaultTransform } = require('./axeTransformer');
const { safeUrl, scrubUrls } = require('./redactUrl');
const defaultAxe = require('axe-core');

/**
Expand Down Expand Up @@ -75,7 +76,12 @@ async function resolveBrowser(injected) {
* shipped to an aggregator as-is.
*/
function logScan(fields) {
const line = JSON.stringify({ event: 'scan', ...fields });
const line = JSON.stringify({
event: 'scan',
...fields,
url: safeUrl(fields.url),
...(fields.error !== undefined && { error: scrubUrls(fields.error) }),
});
if (fields.outcome === 'ok') console.log(line);
else console.error(line);
}
Expand Down
19 changes: 19 additions & 0 deletions backend/tests/redactUrl.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { safeUrl, scrubUrls } = require('../services/redactUrl');

test('safeUrl keeps origin + path and drops query, fragment and credentials', () => {
assert.equal(safeUrl('https://a.test/reset?token=x#y'), 'https://a.test/reset');
assert.equal(safeUrl('https://me:pw@a.test/p'), 'https://a.test/p');
assert.equal(safeUrl('https://a.test:8443/'), 'https://a.test:8443/');
assert.equal(safeUrl('not a url?token=x'), 'not a url');
assert.equal(safeUrl(undefined), '');
});

test('scrubUrls redacts every URL inside free text', () => {
assert.equal(
scrubUrls('failed at https://a.test/x?sig=1 then http://b.test/?t=2'),
'failed at https://a.test/x then http://b.test/',
);
assert.equal(scrubUrls('no urls here'), 'no urls here');
});
17 changes: 17 additions & 0 deletions backend/tests/scan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,20 @@ test('deleteSavedScan returns 404 for SCAN_NOT_FOUND', async () => {
);
assert.equal(out.statusCode, 404);
});

test('scan request logs keep origin + path only', async (t) => {
const logged = [];
t.mock.method(console, 'log', (...args) => logged.push(args.join(' ')));
const app = createTestApp();
const secretUrl = 'https://example.com/reset?token=s3cret';

await request(app).post('/api/scan').send({ url: secretUrl });
await request(app).get('/api/scan-results').query({ url: secretUrl });

const requestLines = logged.filter((l) => l.startsWith('Received'));
assert.equal(requestLines.length, 2);
for (const line of requestLines) {
assert.match(line, /https:\/\/example\.com\/reset$/);
assert.doesNotMatch(line, /s3cret/);
}
});
33 changes: 32 additions & 1 deletion backend/tests/scanRunner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,5 +165,36 @@ test('run emits one structured log line per scan (ok and error)', async (t) => {
assert.equal(typeof lines[0].durationMs, 'number');
assert.equal(lines[1].outcome, 'error');
assert.equal(lines[1].errorClass, 'TimeoutError');
assert.equal(lines[1].url, 'https://example.com');
assert.equal(lines[1].url, 'https://example.com/');
});

test('scan log keeps origin + path only, in the url and in error text', async (t) => {
const lines = [];
t.mock.method(console, 'log', (l) => lines.push(JSON.parse(l)));
t.mock.method(console, 'error', (l) => lines.push(JSON.parse(l)));
const secretUrl = 'https://user:pw@example.com/reset?token=s3cret#frag';

const ok = new ScanRunner({
puppeteer: createMockPuppeteer().puppeteer,
axe: { source: '' },
transform: () => ({ problems: {}, whatsGood: [] }),
validate: () => ({ ok: true }),
});
await ok.run(secretUrl);

// Puppeteer navigation errors quote the URL they failed on.
const navErr = new Error(`net::ERR_NAME_NOT_RESOLVED at ${secretUrl}`);
const bad = new ScanRunner({
puppeteer: createMockPuppeteer({ evaluateError: navErr }).puppeteer,
axe: { source: '' },
validate: () => ({ ok: true }),
});
await bad.run(secretUrl);

assert.equal(lines[0].url, 'https://example.com/reset');
assert.equal(lines[1].url, 'https://example.com/reset');
assert.equal(lines[1].error, 'net::ERR_NAME_NOT_RESOLVED at https://example.com/reset');
for (const line of lines) {
assert.doesNotMatch(JSON.stringify(line), /s3cret|frag|user:pw/);
}
});
Loading