Skip to content

Commit 3a2b121

Browse files
committed
Address review: sanitize HTML link URLs (XSS), fix affected-count zero, DRY report
1 parent 57b4c67 commit 3a2b121

2 files changed

Lines changed: 66 additions & 15 deletions

File tree

breach_scraper/output.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,20 @@ def _html_source_metadata(
215215
return source_key, source_name
216216

217217

218+
def _is_safe_url(url: str) -> bool:
219+
try:
220+
return urlparse(url).scheme in ("http", "https")
221+
except ValueError:
222+
return False
223+
224+
225+
def _safe_notice_url(row: Row) -> str:
226+
url = str(row.get("notice_url", "") or row.get("organization_name_url", "") or "").strip()
227+
return url if _is_safe_url(url) else ""
228+
229+
218230
def _html_notice_link(row: Row) -> str:
219-
notice_url = str(row.get("notice_url", "") or row.get("organization_name_url", "") or "")
231+
notice_url = _safe_notice_url(row)
220232
if not notice_url:
221233
return ""
222234
return (
@@ -240,9 +252,9 @@ def _html_source_link(row: Row, source_name: str) -> str:
240252

241253
def _html_organization_link(row: Row) -> str:
242254
organization_name = str(row.get("organization_name", "") or "Unknown organization")
243-
organization_url = str(row.get("organization_name_url", "") or "")
255+
organization_url = str(row.get("organization_name_url", "") or "").strip()
244256
safe_name = html.escape(organization_name)
245-
if not organization_url:
257+
if not _is_safe_url(organization_url):
246258
return safe_name
247259
return (
248260
f'<a href="{html.escape(organization_url, quote=True)}" target="_blank" rel="noopener noreferrer">'
@@ -275,13 +287,16 @@ def _html_reported_date(row: Row) -> str:
275287

276288

277289
def _html_affected_count(row: Row) -> str:
278-
return str(
279-
row.get("persons_affected", "")
280-
or row.get("total_persons_affected", "")
281-
or row.get("number_affected", "")
282-
or row.get("individuals_affected", "")
283-
or ""
284-
)
290+
for key in (
291+
"persons_affected",
292+
"total_persons_affected",
293+
"number_affected",
294+
"individuals_affected",
295+
):
296+
value = row.get(key)
297+
if value is not None and value != "":
298+
return str(value)
299+
return ""
285300

286301

287302
def to_html(
@@ -356,7 +371,7 @@ def to_html(
356371
f'data-sort-date_of_breach="{html.escape(_date_sort_key(breach_display), quote=True)}" '
357372
f'data-sort-affected="{affected_numeric if affected_numeric is not None else 0}" '
358373
f'data-sort-information="{html.escape(str(row.get("information_compromised", "") or "").casefold(), quote=True)}" '
359-
f'data-sort-notice="{html.escape(str(row.get("notice_url", "") or row.get("organization_name_url", "") or ""), quote=True)}"'
374+
f'data-sort-notice="{html.escape(_safe_notice_url(row), quote=True)}"'
360375
f">"
361376
f'<td class="source-cell">{_html_source_link(row, source_name)}</td>'
362377
f"<td>{html.escape(reported_display or 'Unknown')}</td>"
@@ -963,10 +978,7 @@ def to_report(
963978
lines.append(f"### {idx}. {name}")
964979
lines.append(f"- Date reported: {row.get('date_reported', '') or 'Unknown'}")
965980
lines.append(f"- Date of breach: {row.get('date_of_breach', '') or 'Unknown'}")
966-
lines.append(
967-
"- Persons affected: "
968-
f"{row.get('persons_affected', '') or row.get('total_persons_affected', '') or row.get('number_affected', '') or row.get('individuals_affected', '') or 'Unknown'}"
969-
)
981+
lines.append(f"- Persons affected: {_html_affected_count(row) or 'Unknown'}")
970982
lines.append(
971983
f"- Information compromised: {row.get('information_compromised', '') or 'Not listed'}"
972984
)

tests/test_output.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
5+
from breach_scraper.output import (
6+
_html_affected_count,
7+
_html_notice_link,
8+
_html_organization_link,
9+
)
10+
11+
12+
class TestHtmlSafety(unittest.TestCase):
13+
def test_notice_link_rejects_javascript_scheme(self) -> None:
14+
self.assertEqual(_html_notice_link({"notice_url": "javascript:alert(1)"}), "")
15+
16+
def test_notice_link_allows_https(self) -> None:
17+
link = _html_notice_link({"notice_url": "https://example.gov/notice.pdf"})
18+
self.assertIn('href="https://example.gov/notice.pdf"', link)
19+
20+
def test_organization_link_rejects_javascript_scheme(self) -> None:
21+
out = _html_organization_link(
22+
{"organization_name": "Acme", "organization_name_url": "javascript:alert(1)"}
23+
)
24+
self.assertEqual(out, "Acme")
25+
self.assertNotIn("href", out)
26+
27+
28+
class TestAffectedCount(unittest.TestCase):
29+
def test_zero_is_not_skipped(self) -> None:
30+
self.assertEqual(_html_affected_count({"persons_affected": 0}), "0")
31+
32+
def test_empty_falls_through(self) -> None:
33+
self.assertEqual(
34+
_html_affected_count({"persons_affected": "", "number_affected": "5"}), "5"
35+
)
36+
37+
38+
if __name__ == "__main__":
39+
unittest.main()

0 commit comments

Comments
 (0)