Skip to content

Commit ba4b525

Browse files
authored
Merge pull request #42 from happyprime/task/report-hardening
Report hardening: escaping, config validation, modal dialog (MED-001/011 + 5 low)
2 parents f3f75be + 7285704 commit ba4b525

7 files changed

Lines changed: 345 additions & 41 deletions

File tree

src/config.mjs

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,55 @@ export function normalizeDomain(domain) {
6969
return value;
7070
}
7171

72+
// Path keys and viewport names become part of output filenames (the
73+
// `${key}-${viewport}` slug), so they must not contain path separators or
74+
// traversal sequences that could redirect a write outside the output dir.
75+
const SAFE_SLUG_PART = /^[a-z0-9][a-z0-9._-]*$/i;
76+
77+
/**
78+
* Assert a value is safe to use as part of an output filename.
79+
*
80+
* @param {string} value - The value to check.
81+
* @param {string} kind - A label for the error message (e.g. "path key").
82+
*/
83+
function assertSafeSlugPart(value, kind) {
84+
if (
85+
typeof value !== 'string' ||
86+
!SAFE_SLUG_PART.test(value) ||
87+
value.includes('..')
88+
) {
89+
throw new Error(
90+
`❌ Invalid ${kind} ${JSON.stringify(value)}: it becomes part of an ` +
91+
'output filename, so use only letters, numbers, dashes, dots, ' +
92+
'and underscores (no slashes or "..").'
93+
);
94+
}
95+
}
96+
97+
/**
98+
* Validate a pixelmatch color option is an [r, g, b] triple of 0–255 integers.
99+
*
100+
* Without this, a non-array value (e.g. `null`) crashes report generation with
101+
* a `.join` TypeError at the very end of a compare run, and a crafted array
102+
* would be interpolated into the report's CSS.
103+
*
104+
* @param {*} color - The configured color value.
105+
* @param {string} name - The option name, for the error message.
106+
*/
107+
function validateColor(color, name) {
108+
const valid =
109+
Array.isArray(color) &&
110+
color.length === 3 &&
111+
color.every((n) => Number.isInteger(n) && n >= 0 && n <= 255);
112+
113+
if (!valid) {
114+
throw new Error(
115+
`❌ Invalid "pixelmatchOptions.${name}": expected [r, g, b] integers 0–255, ` +
116+
`got ${JSON.stringify(color)}.`
117+
);
118+
}
119+
}
120+
72121
/**
73122
* Validate the viewports defined in a config, throwing on the first problem.
74123
*
@@ -105,6 +154,8 @@ export function validateViewports(viewports) {
105154
);
106155
}
107156

157+
assertSafeSlugPart(viewport.name, 'viewport name');
158+
108159
for (const dimension of ['width', 'height']) {
109160
const value = viewport[dimension];
110161
if (!Number.isInteger(value) || value <= 0) {
@@ -193,6 +244,10 @@ export function loadConfig({ configPath = 'reglance.json', domain } = {}) {
193244
throw new Error('No "paths" configured in reglance.json.');
194245
}
195246

247+
for (const key of Object.keys(raw.paths)) {
248+
assertSafeSlugPart(key, 'path key');
249+
}
250+
196251
// The domain is only required for capture; control and compare operate on
197252
// already-captured files, so a missing domain is allowed here.
198253
const resolvedDomain = domain ?? raw.domain;
@@ -210,16 +265,20 @@ export function loadConfig({ configPath = 'reglance.json', domain } = {}) {
210265
url: origin ? buildUrl(origin, pathname) : pathname,
211266
}));
212267

268+
const pixelmatchOptions = {
269+
...DEFAULT_PIXELMATCH_OPTIONS,
270+
...raw.pixelmatchOptions,
271+
};
272+
validateColor(pixelmatchOptions.diffColor, 'diffColor');
273+
validateColor(pixelmatchOptions.diffColorAlt, 'diffColorAlt');
274+
213275
return {
214276
name: raw.name || (origin ? new URL(origin).host : 'reglance'),
215277
domain: origin,
216278
outputDir,
217279
viewports,
218280
targets,
219-
pixelmatchOptions: {
220-
...DEFAULT_PIXELMATCH_OPTIONS,
221-
...raw.pixelmatchOptions,
222-
},
281+
pixelmatchOptions,
223282
timeouts: {
224283
...DEFAULT_TIMEOUTS,
225284
...raw.timeouts,

src/report.mjs

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,25 @@ import { fileURLToPath } from 'node:url';
44

55
const TEMPLATES_DIR = fileURLToPath(new URL('../templates', import.meta.url));
66

7+
const templateCache = new Map();
8+
79
/**
810
* Read a template file that ships with the package.
911
*
12+
* Memoized: templates don't change during a run, and these are read once per
13+
* comparison, so caching avoids hundreds of redundant blocking reads (and
14+
* filesystem contention once compare is parallelized).
15+
*
1016
* @param {string} name - The template filename.
1117
* @returns {string} The template contents.
1218
*/
1319
function readTemplate(name) {
14-
return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
20+
let cached = templateCache.get(name);
21+
if (cached === undefined) {
22+
cached = fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
23+
templateCache.set(name, cached);
24+
}
25+
return cached;
1526
}
1627

1728
/**
@@ -32,15 +43,31 @@ export function copyAssets(config) {
3243
* @param {string} str - The string to escape.
3344
* @returns {string} The escaped string.
3445
*/
35-
function escapeHtml(str) {
36-
return str
46+
export function escapeHtml(str) {
47+
return String(str)
3748
.replace(/&/g, '&amp;')
3849
.replace(/</g, '&lt;')
3950
.replace(/>/g, '&gt;')
4051
.replace(/"/g, '&quot;')
4152
.replace(/'/g, '&#039;');
4253
}
4354

55+
/**
56+
* Serialize a value for embedding in an inline <script>.
57+
*
58+
* JSON.stringify does not neutralize `</script>` or the U+2028/U+2029 line
59+
* separators, which can break out of the script element, so escape them.
60+
*
61+
* @param {*} value - The value to serialize.
62+
* @returns {string} Script-safe JSON.
63+
*/
64+
function jsonForScript(value) {
65+
return JSON.stringify(value)
66+
.replace(/</g, '\\u003c')
67+
.replace(/\u2028/g, '\\u2028')
68+
.replace(/\u2029/g, '\\u2029');
69+
}
70+
4471
/**
4572
* Build an HTML diff report comparing two HTML snapshots.
4673
*
@@ -76,9 +103,9 @@ export function generateHtmlDiff(html1, html2, context, diffLines) {
76103

77104
const template = readTemplate('html-diff.html');
78105
const html = template
79-
.replaceAll('{name}', name)
80-
.replaceAll('{urlKey}', urlKey)
81-
.replaceAll('{viewportName}', viewport.name)
106+
.replaceAll('{name}', escapeHtml(name))
107+
.replaceAll('{urlKey}', escapeHtml(urlKey))
108+
.replaceAll('{viewportName}', escapeHtml(viewport.name))
82109
.replaceAll('{viewportWidth}', String(viewport.width))
83110
.replaceAll('{viewportHeight}', String(viewport.height))
84111
.replaceAll('{status}', hasChanges ? 'Changes detected' : 'No changes')
@@ -110,11 +137,11 @@ export function generateReport(config, report) {
110137
const overlayAlt = `Original (control) capture of ${where}`;
111138

112139
template = template
113-
.replaceAll('{name}', name)
140+
.replaceAll('{name}', escapeHtml(name))
114141
.replaceAll('{baseAlt}', baseAlt)
115142
.replaceAll('{overlayAlt}', overlayAlt)
116-
.replaceAll('{urlKey}', urlKey)
117-
.replaceAll('{viewportName}', viewport.name)
143+
.replaceAll('{urlKey}', escapeHtml(urlKey))
144+
.replaceAll('{viewportName}', escapeHtml(viewport.name))
118145
.replaceAll('{viewportWidth}', String(viewport.width))
119146
.replaceAll('{viewportHeight}', String(viewport.height))
120147
.replaceAll('{originalImage}', rel(controlImage))
@@ -175,38 +202,40 @@ export function generateIndex(config, reports) {
175202
? 'medium'
176203
: 'low';
177204
const htmlDiffClass = report.htmlHasChanges ? 'high' : 'low';
205+
const url = escapeHtml(report.url);
206+
const viewportName = escapeHtml(report.viewport.name);
178207

179208
return `
180-
<tr data-url="${report.url}" data-viewport="${report.viewport.name}" data-diff="${report.diffPercentage}" data-index="${index}">
181-
<td class="url-cell" title="${report.url}">${report.url}</td>
182-
<td>${report.viewport.name} (${report.viewport.width}x${report.viewport.height})</td>
209+
<tr data-url="${url}" data-viewport="${viewportName}" data-diff="${report.diffPercentage}" data-index="${index}">
210+
<td class="url-cell" title="${url}">${url}</td>
211+
<td>${viewportName} (${report.viewport.width}x${report.viewport.height})</td>
183212
<td class="diff-percentage ${diffClass}"><span class="visually-hidden">${diffClass} difference: </span>${report.diffPercentage.toFixed(2)}%</td>
184213
<td class="diff-percentage ${htmlDiffClass}">${report.htmlHasChanges ? 'Yes' : 'No'}</td>
185214
<td><a href="${rel(report.reportPath)}">View Report</a></td>
186-
<td><a href="#" onclick="openModal(window.diffData, ${index}); return false;">View Diff</a></td>
215+
<td><button type="button" class="link-button" onclick="openModal(window.diffData, ${index}, this)">View Diff</button></td>
187216
<td><a href="${rel(report.htmlDiffPath)}">View HTML Diff</a></td>
188217
</tr>`;
189218
})
190219
.join('');
191220

192221
const viewportOptions = viewports
193-
.map(
194-
(v) =>
195-
`<option value="${v.name}">${v.name} (${v.width}x${v.height})</option>`
196-
)
222+
.map((v) => {
223+
const vn = escapeHtml(v.name);
224+
return `<option value="${vn}">${vn} (${v.width}x${v.height})</option>`;
225+
})
197226
.join('\n\t\t\t\t');
198227

199228
const template = readTemplate('index.html');
200229
const indexHtml = template
201-
.replaceAll('{name}', name)
230+
.replaceAll('{name}', escapeHtml(name))
202231
.replaceAll('{threshold}', String(pixelmatchOptions.threshold))
203232
.replaceAll('{includeAA}', pixelmatchOptions.includeAA ? 'Yes' : 'No')
204233
.replaceAll('{alpha}', String(pixelmatchOptions.alpha))
205234
.replaceAll('{diffColor}', pixelmatchOptions.diffColor.join(','))
206235
.replaceAll('{viewportOptions}', viewportOptions)
207236
.replaceAll('{rows}', rows)
208237
.replaceAll('{diffViewer}', diffViewer)
209-
.replaceAll('{diffData}', JSON.stringify(diffData));
238+
.replaceAll('{diffData}', jsonForScript(diffData));
210239

211240
const indexPath = path.join(dirs.reports, 'index.html');
212241
fs.writeFileSync(indexPath, indexHtml);

templates/assets/index-style.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,26 @@ a:hover {
3434
text-decoration: underline;
3535
}
3636

37+
/* A button that performs an in-page action (opening the diff dialog) but
38+
reads like the surrounding navigation links. */
39+
.link-button {
40+
background: none;
41+
border: 0;
42+
padding: 0;
43+
font: inherit;
44+
color: #0066cc;
45+
cursor: pointer;
46+
}
47+
48+
.link-button:hover {
49+
text-decoration: underline;
50+
}
51+
52+
.link-button:focus-visible {
53+
outline: 2px solid #0066cc;
54+
outline-offset: 2px;
55+
}
56+
3757
.diff-percentage {
3858
font-weight: bold;
3959
}

0 commit comments

Comments
 (0)