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", 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}