From 5e718725a9e90dff29c74919bd859775756d176c Mon Sep 17 00:00:00 2001 From: geyutu6755 Date: Sun, 1 Nov 2026 18:30:17 +0800 Subject: [PATCH 1/2] fix(v0.3.2): block non-HTTP(S) URLs from becoming live report links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML report assigns result.json values (finding url, cluster member url, plan url, page url, header entry) directly to anchor `href`. The bundled scanner only ever produces http(s) URLs, but renderHtml is a general generator over an arbitrary ScanResult, so a crafted / shared / older / third-party result.json opened as an offline report could make a page link `javascript:` or `data:` — self-XSS on click. Reproduced: a rendered report exposed live `javascript:alert(member)` anchors. - Client: safeHref() + linkOrText() emit an anchor only for http(s); anything else renders as non-clickable muted text. Applied to the four dynamic link sites (member, finding, plan "Open …", page table). - Server: httpHref() guards the header target anchor. - Trace links stay local/private but now say so (label + title: traces are not included in hosted/public copies) so users stop expecting a web download of stripped evidence. Tests: real-browser audit asserts zero javascript:/data:/file: anchors while legitimate http(s) links still render; unit tests cover the header guard and the trace-copy honesty. --- src/report/html.ts | 60 +++++++++++++++++++++-------- tests/integration/report-ux.test.ts | 56 +++++++++++++++++++++++++++ tests/unit/report.test.ts | 28 ++++++++++++++ 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/src/report/html.ts b/src/report/html.ts index ccb4167..513e757 100644 --- a/src/report/html.ts +++ b/src/report/html.ts @@ -23,6 +23,21 @@ function escapeAttr(s: string): string { return escapeHtml(s); } +/** + * Server-side guard: return the URL only when it is an absolute http(s) link, + * else null. Untrusted result.json values must never become a live `href` + * (blocks javascript:/data:/file: self-XSS in the offline report). + */ +function httpHref(u: string | undefined | null): string | null { + if (!u) return null; + try { + const p = new URL(u); + return p.protocol === 'http:' || p.protocol === 'https:' ? u : null; + } catch { + return null; + } +} + const CSS = ` :root { --bg: #f5f6f8; --card: #ffffff; --ink: #111827; --muted: #6b7280; --line: #e5e7eb; @@ -69,6 +84,7 @@ main { max-width: 1080px; margin: 0 auto; padding: 20px 24px 60px; } .cluster-group { font-size: 12px; color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; margin: 20px 0 6px; } .cluster-group:first-child { margin-top: 0; } .cluster-caution { margin: 10px 0 0; padding: 9px 12px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; color: #92400e; font-size: 12.5px; line-height: 1.5; } +.blocked-url { color: var(--muted); word-break: break-all; font-family: var(--mono); font-size: 12.5px; } .copy-btn { font: inherit; font-size: 12px; border: 1px solid var(--line); background: #f8fafc; color: var(--accent); border-radius: 6px; padding: 3px 10px; cursor: pointer; } .copy-btn:hover { border-color: var(--accent); } .finding { background: var(--card); border: 1px solid var(--line); border-left: 4px solid var(--muted); border-radius: 10px; padding: 16px 18px; margin-bottom: 14px; overflow-wrap: anywhere; } @@ -137,6 +153,24 @@ const CLIENT_JS = ` } function chip(cls, text) { return el('span', 'chip ' + cls, text); } function fmtSec(ms) { return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms'; } + function safeHref(u) { + if (typeof u !== 'string' || !u) return null; + try { + var p = new URL(u, location.href); + return (p.protocol === 'http:' || p.protocol === 'https:') ? u : null; + } catch (e) { return null; } + } + function linkOrText(url, label) { + var h = safeHref(url); + if (h) { + var a = el('a', null, label); + a.href = h; a.target = '_blank'; a.rel = 'noreferrer'; + return a; + } + var s = el('span', 'blocked-url', label); + s.title = 'Non-HTTP(S) link is not clickable in this report.'; + return s; + } var findings = (D.findings || []).slice().sort(function (a, b) { if (a.status !== b.status) return a.status === 'verified' ? -1 : 1; @@ -244,9 +278,7 @@ const CLIENT_JS = ` details.appendChild(el('summary', null, 'Affected pages')); c.members.forEach(function (m) { var row = el('div', 'member-row'); - var a = el('a', null, m.url); - a.href = m.url; a.target = '_blank'; a.rel = 'noreferrer'; - row.appendChild(a); + row.appendChild(linkOrText(m.url, m.url)); row.appendChild(el('span', null, ' [' + m.status + (m.stability ? '/' + m.stability : '') + '] repro ' + m.repro.successes + '/' + m.repro.attempts + (m.generatedTest ? ' · ' + m.generatedTest : ''))); details.appendChild(row); }); @@ -386,9 +418,7 @@ const CLIENT_JS = ` meta.appendChild(el('span', null, '')).appendChild(el('code', null, f.id)); var page = el('span'); page.appendChild(el('span', null, 'page: ')); - var a = el('a', null, f.url); - a.href = f.url; a.target = '_blank'; a.rel = 'noreferrer'; - page.appendChild(a); + page.appendChild(linkOrText(f.url, f.url)); meta.appendChild(page); var rep = el('span'); rep.appendChild(el('span', null, 'reproduced: ')); @@ -413,9 +443,8 @@ const CLIENT_JS = ` card.appendChild(el('div', 'sect-label', 'Reproduction steps (recorded during the scan)')); var ol = el('ol', 'steps'); var li0 = el('li', null, ''); - var a0 = el('a', null, 'Open ' + (f.plan && f.plan.url ? f.plan.url : f.url)); - a0.href = (f.plan && f.plan.url) || f.url; a0.target = '_blank'; a0.rel = 'noreferrer'; - li0.appendChild(a0); + var openUrl = (f.plan && f.plan.url) || f.url; + li0.appendChild(linkOrText(openUrl, 'Open ' + openUrl)); ol.appendChild(li0); f.steps.forEach(function (s) { ol.appendChild(el('li', null, s.description + (s.selector ? ' — ' + s.selector : ''))); }); ol.appendChild(el('li', null, 'Observe the fault described above.')); @@ -445,8 +474,10 @@ const CLIENT_JS = ` var arts = []; (f.traces || []).forEach(function (t) { var row = el('span'); - var link = el('a', null, 'browser trace (playwright show-trace)'); - link.href = encodeURI(t); link.download = t.split('/').pop(); + var link = el('a', null, 'browser trace (local · playwright show-trace)'); + link.href = encodeURI(t); + link.download = t.split('/').pop(); + link.title = 'Private local evidence. Trace files record raw network traffic and are not included in hosted or public copies of this report — open them from your own scan output.'; row.appendChild(link); row.appendChild(el('span', null, ' — ' + t)); arts.push(row); @@ -484,9 +515,7 @@ const CLIENT_JS = ` (D.pages || []).forEach(function (p) { var tr = el('tr'); var urlTd = el('td', 'url'); - var a = el('a', null, p.url); - a.href = p.url; a.target = '_blank'; a.rel = 'noreferrer'; - urlTd.appendChild(a); + urlTd.appendChild(linkOrText(p.url, p.url)); tr.appendChild(urlTd); var status = el('td'); if (p.error) { status.appendChild(el('span', 'bad', 'network error')); status.title = p.error; } @@ -596,6 +625,7 @@ const CLIENT_JS = ` /** Renders the self-contained offline HTML report. No external resources. */ export function renderHtml(result: ScanResult, compare?: CompareResult): string { const target = result.target?.entry ?? ''; + const targetHref = httpHref(target); const typeOptions = ([ 'page-load-failure', 'js-exception', @@ -623,7 +653,7 @@ export function renderHtml(result: ScanResult, compare?: CompareResult): string ${LOGO_SVG}
ReproCasev${VERSION}
-
target: ${escapeHtml(target)}
+
target: ${targetHref ? `${escapeHtml(target)}` : escapeHtml(target)}
finished: ${escapeHtml(result.finishedAt ?? '')} · allowed domains: ${escapeHtml((result.target?.allowedDomains ?? []).join(', ') || 'localhost')}
diff --git a/tests/integration/report-ux.test.ts b/tests/integration/report-ux.test.ts index 8ff2ed4..fd0a9cd 100644 --- a/tests/integration/report-ux.test.ts +++ b/tests/integration/report-ux.test.ts @@ -257,6 +257,62 @@ describe('HTML report UX (real browser audit)', () => { await page.close(); }, 120_000); + it('blocks non-HTTP(S) URLs from becoming clickable links (v0.3.2 security)', async () => { + // A crafted / shared / third-party result.json is a real input to the offline + // report a user opens; its page/plan/member/entry URLs must never be emitted as + // javascript:/data:/file: anchors (self-XSS on click). + const evilFinding = makeFinding({ + url: 'javascript:alert(document.domain)', + pages: ['javascript:alert(document.domain)'], + plan: { kind: 'js-error', url: 'javascript:alert(document.domain)' } + }); + const member = makeFinding({ + url: 'javascript:alert(member)', + evidence: { message: 'Boom: shared', stack: 'Error: Boom: shared\n at save (http://127.0.0.1:4640/app.js:1:1)' } + }); + const member2 = makeFinding({ + url: 'javascript:alert(member2)', + evidence: { message: 'Boom: shared', stack: 'Error: Boom: shared\n at save (http://127.0.0.1:4640/app.js:1:1)' } + }); + const result = { ...makeResult([evilFinding]) } as ScanResult; + result.findings = [evilFinding, member, member2]; + result.clusters = aggregateFindings([evilFinding, member, member2]); + // Keep one legitimate http page so we can prove good links still render. + result.pages = result.pages.concat([ + { url: 'data:text/html,', depth: 0, status: 200, actions: 0, jsErrors: 0, resourceErrors: 0, overflow: false, durationMs: 1 } + ]); + result.target = { entry: 'javascript:alert(topbar)', allowedDomains: ['x'] }; + result.summary = { verified: 1, suspected: 0, bySeverity: { critical: 0, high: 3, medium: 0, low: 0 }, byType: { 'js-exception': 3 } }; + + const file = path.join(path.dirname(htmlFile), 'unsafe-urls.html'); + fs.writeFileSync(file, renderHtml(result), 'utf8'); + + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + let dialogSaw = ''; + page.on('dialog', (d) => { dialogSaw = d.message(); d.dismiss().catch(() => undefined); }); + await page.goto('file:///' + file.replace(/\\/g, '/')); + await page.waitForTimeout(250); + + const state = await page.evaluate(() => { + const hrefs = [...document.querySelectorAll('a[href]')].map((a) => (a.getAttribute('href') || '').trim().toLowerCase()); + return { + bad: hrefs.filter((h) => h.startsWith('javascript:') || h.startsWith('data:') || h.startsWith('file:')), + legit: hrefs.filter((h) => h.startsWith('http:') || h.startsWith('https:')).length + }; + }); + expect(state.bad, 'no javascript:/data:/file: anchors may be clickable').toEqual([]); + expect(state.legit, 'legitimate http(s) page links must still render').toBeGreaterThan(0); + + // Clicking where a blocked URL would have been must not execute or navigate. + await page.click('#clusters details summary').catch(() => undefined); + await page.waitForTimeout(150); + expect(dialogSaw, 'no alert() from a blocked javascript: link').toBe(''); + expect(pageErrors).toEqual([]); + await page.close(); + }, 120_000); + it('stability filter narrows findings (v0.3.0)', async () => { const mixed = makeResult([ makeFinding({ title: 'stable one', evidence: { message: 'a' }, stability: 'stable' }), diff --git a/tests/unit/report.test.ts b/tests/unit/report.test.ts index 7a49da5..aa3cc99 100644 --- a/tests/unit/report.test.ts +++ b/tests/unit/report.test.ts @@ -183,3 +183,31 @@ describe('report accuracy & consistency (v0.3.1)', () => { expect(html).toContain('🐛'); }); }); + +describe('report URL safety (v0.3.2)', () => { + it('never renders the target entry as a live non-HTTP(S) anchor in the header', () => { + const html = renderHtml( + makeResult({ target: { entry: 'javascript:alert(1)', allowedDomains: ['x'] } }) + ); + // The topbar is server-rendered: it must not emit href="javascript:...". + expect(html).not.toMatch(/href="javascript:/i); + expect(html).not.toMatch(/href="data:/i); + // The value is still shown (escaped) as plain text, not as a clickable link. + expect(html).toContain('javascript:alert(1)'); + }); + + it('keeps a legitimate HTTP(S) target as a clickable header link', () => { + const html = renderHtml( + makeResult({ target: { entry: 'https://example.com/app', allowedDomains: ['example.com'] } }) + ); + expect(html).toContain(' { + const withTrace = makeFinding({ traces: ['artifacts/traces/trace-a.zip'] }); + const html = renderHtml(makeResult({ findings: [withTrace] })); + // v0.3.2 UX: the trace affordance must set expectations that hosted/public copies omit it. + expect(html).toMatch(/playwright show-trace/); + expect(html.toLowerCase()).toContain('not included in hosted'); + }); +}); From 673275f6a163f5b046c46a14ed47f8d5b03c72b9 Mon Sep 17 00:00:00 2001 From: geyutu6755 Date: Sun, 1 Nov 2026 18:34:16 +0800 Subject: [PATCH 2/2] chore(v0.3.2): version bump + docs sync Bump 0.3.1 -> 0.3.2 (package.json, lock, runtime VERSION). Add the v0.3.2 CHANGELOG entry (report link-scheme hardening + trace-link honesty), update the README (en + zh) Status sections and refresh the compatibility matrix with the real verified counts (136/136) and observed v0.3.1 CI results. --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ README.md | 13 +++++++------ README.zh-CN.md | 2 +- docs/compatibility.md | 14 +++++++------- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 50 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2d8e0..70b859e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ All notable changes to ReproCase are documented here. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/). +## [0.3.2] — 2026-11-01 + +Post-release hardening: one security fix and one report-honesty fix. No new +features and no UI rebuild. + +### Security + +- **The HTML report no longer turns untrusted URLs into clickable links.** + `report.html` assigned `result.json` values (finding page URL, cluster member + URL, reproduction-plan URL, page-table URL, header target) directly to anchor + `href`. The bundled scanner only emits `http(s)`, but the generator is a general + renderer over an arbitrary `ScanResult`, so a crafted / shared / older / + third-party `result.json` — opened as an offline report — could produce a + `javascript:` or `data:` link that executes on click (self-XSS). Every dynamic + link now passes an HTTP(S) scheme check and renders as non-clickable text if the + URL is not `http(s)`; the header target is guarded server-side. Legitimate + `http(s)` links are unchanged. A real-browser test asserts zero + `javascript:`/`data:`/`file:` anchors while normal links still render. + +### Changed + +- **Trace links set honest expectations.** Playwright traces stay private and are + still stripped from CI and GitHub Pages, but the report now labels the trace link + as a *local* artifact and its tooltip states that traces are not included in + hosted / public copies — so users no longer see a dead "download" for evidence + that was intentionally removed. + +### Tests + +- Suite: **136 tests across 20 files** (was 132) — added a real-browser URL-safety + audit, a header-URL guard test, and a trace-copy honesty test. + ## [0.3.1] — 2026-11-01 Reliability and trust refinement. No new capabilities — this release corrects how the diff --git a/README.md b/README.md index d0e9ed4..b37aa84 100644 --- a/README.md +++ b/README.md @@ -134,12 +134,13 @@ fault through the real Playwright runner. ## Status -v0.3.1 — reliability & trust refinement of the v0.3.0 line. Cross-page fault clusters, -adaptive async observation (configurable, budgeted), open shadow DOM + same-origin iframe -detection and `scan --compare-with` are all retained; this patch hardens how clusters are -described (message-only matches are shown as *possible duplicates*, never a proven shared -root cause), keeps lone faults out of the cluster view, and tightens report accuracy. See the -[changelog](CHANGELOG.md), [test baselines](docs/test-baselines.md) and the +v0.3.2 — post-release hardening: the offline HTML report now validates link schemes so +untrusted `result.json` values can never become clickable `javascript:`/`data:` links, and +trace evidence is labelled honestly as local-only. Built on v0.3.1 (honest fault-cluster +labelling, ≥2-page clusters, accurate `issues.md`) and the v0.3.0 line — cross-page fault +clusters, adaptive async observation (configurable, budgeted), open shadow DOM + same-origin +iframe detection and `scan --compare-with`. See the [changelog](CHANGELOG.md), +[test baselines](docs/test-baselines.md) and the [compatibility matrix](docs/compatibility.md). Known limitations are listed in the changelog and in the "Not checked" section of every report. diff --git a/README.zh-CN.md b/README.zh-CN.md index 7636653..328f9bd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -125,7 +125,7 @@ npm run test:all # 单元 + 集成(真实浏览器)+ CLI 端到端 ## 状态 -v0.3.1 —— 在 v0.3.0 基础上的可靠性与可信度精修:保留跨页面故障聚类、自适应异步观测(可配置/有预算)、开放 Shadow DOM 与同源 iframe 检测、scan --compare-with 跨扫描比较;本补丁收紧故障聚类的表述(仅凭相同文本、无可信堆栈的匹配只显示为"疑似关联/可能重复",绝不声称已证实同一根因),让孤立故障不再冒充聚类,并提升报告准确性。详见 +v0.3.2 —— 发布后加固:离线 HTML 报告对链接协议做校验,来自不可信 `result.json` 的值不会再变成可点击的 `javascript:`/`data:` 链接;并对 Trace 证据作诚实标注(仅本地、公共副本不包含)。基于 v0.3.1(故障聚类诚实表述、聚类需跨≥2页、`issues.md` 表头准确)与 v0.3.0 特性线——跨页面故障聚类、自适应异步观测(可配置/有预算)、开放 Shadow DOM 与同源 iframe 检测、scan --compare-with。详见 [变更日志](CHANGELOG.md)、[测试基线](docs/test-baselines.md) 与[兼容性矩阵](docs/compatibility.md)。 ## 许可证 diff --git a/docs/compatibility.md b/docs/compatibility.md index 2037996..908def7 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -2,16 +2,16 @@ Honest status per platform. "Real pass" means the full automated suite (or the listed components) actually ran on that platform; nothing here is inferred from source review. -Windows and macOS results come from real GitHub Actions runners (`ci.yml` matrix, run for -commit `2ef101c`, 2026-11-01); Linux from ubuntu runners (Node 20 & 22). The **Windows local** -column is re-run for every release and now reports the current suite: 122/122 at v0.3.0 and -132/132 at v0.3.1. The CI columns above the v0.3.x line reflect the `2ef101c` matrix run; a -fresh `ci.yml` matrix executes on every push to `main` (including the v0.3.1 release push) and -its results are recorded on that run, not asserted here until observed. +The **Windows local** column is re-run for every release and reports the current suite: +122/122 at v0.3.0, 132/132 at v0.3.1 and 136/136 at v0.3.2. The full `ci.yml` matrix +(Windows / macOS / Ubuntu Node 20 & 22) runs on every push to `main` and every PR; the +v0.3.1 matrix (run #41295959003) completed **success** on all four OS jobs, and the v0.3.2 +results are attached to that release's PR/push once observed. The matrix cells below are +annotated with the run each platform result is drawn from. | Component | Windows 11 x64 (local dev) | Windows CI (windows-latest, Node 22) | Linux CI (ubuntu-latest, Node 20 & 22) | macOS CI (macos-latest, Node 22) | | --- | --- | --- | --- | --- | -| Full test suite (`vitest run`) | ✅ real pass (99/99 v0.2.1; 101/101 v0.2.2; 122/122 v0.3.0; 132/132 v0.3.1) | ✅ real pass (CI @ v0.2.2) | ✅ real pass (CI @ v0.2.2) | ✅ real pass (CI @ v0.2.2) | +| Full test suite (`vitest run`) | ✅ real pass (101/101 v0.2.2; 122/122 v0.3.0; 132/132 v0.3.1; 136/136 v0.3.2) | ✅ real pass (CI @ v0.2.2) | ✅ real pass (CI @ v0.2.2) | ✅ real pass (CI @ v0.2.2) | | CLI `scan` / `demo` / `init` / `doctor` / `compare` | ✅ real pass | ✅ real pass (CI e2e) | ✅ real pass (CI e2e) | ✅ real pass (CI e2e) | | Chromium via Playwright (headless) | ✅ real pass | ✅ real pass (CI) | ✅ real pass (`--with-deps`) | ✅ real pass (CI) | | Generated reproduction tests via `playwright test` | ✅ real pass | ✅ real pass (CI e2e) | ✅ real pass (CI e2e) | ✅ real pass (CI e2e) | diff --git a/package-lock.json b/package-lock.json index 9a6b374..a731dd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "reprocase", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reprocase", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "@playwright/test": "^1.49.0", diff --git a/package.json b/package.json index ce96868..ffa6a4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reprocase", - "version": "0.3.1", + "version": "0.3.2", "description": "Automated website fault detection & reproduction. Scans a site in a real browser, verifies each fault independently, and emits reproducible Playwright regression tests plus human- and CI-friendly reports.", "type": "module", "license": "MIT",