H1+M2 review findings: CSV 5000x50 render cap (truncated with note) + stale output sweep; 4 regression tests (H1x3, M2x1); USER_GUIDE truncation docs
Test / Tests (Python 3.12) (push) Canceled after 0s
Test / Tests (Python 3.12) (push) Canceled after 0s
This commit is contained in:
@@ -7,18 +7,26 @@ C2 - Remote resource fetching by WeasyPrint (blind SSRF / server-side data egres
|
||||
The server must never fetch a remote http(s)/ftp/ws/wss resource on behalf of a
|
||||
user upload. The fix lives in converters.common._blocking_url_fetcher
|
||||
(common.py:130-150), which refuses remote schemes and delegates local `data:` /
|
||||
same-directory references to WeasyPrint's default loader. The import that makes
|
||||
that delegation possible is converters.common:18.
|
||||
same-directory references to WeasyPrint's default loader.
|
||||
|
||||
Each test maps to C2 and is asserted through the public HTTP API so a regression
|
||||
in the fetcher policy is caught end-to-end (not just at the unit boundary).
|
||||
C1 - Decompression-bomb guard on zip extraction (app.py zip-bomb guard + the
|
||||
SHARED streaming guard common._copy_stream_with_cap).
|
||||
|
||||
Satisfies: C2
|
||||
C3 - Stored-XSS stripping in rendered HTML/SVG (common._strip_scripts in
|
||||
common.html_to_pdf_bytes, called by the pandoc/html/svg converters).
|
||||
|
||||
H1 - CSV row/column render cap in converters.csv_converter._table_html (rows
|
||||
capped to MAX_ROWS, columns to MAX_COLS, visible truncation note).
|
||||
|
||||
M2 - Stale output-file sweep (app._sweep_stale_output_files).
|
||||
|
||||
Satisfies: C1, C2, C3, H1, M2
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
@@ -75,10 +83,6 @@ def test_remote_https_image_reference_rejected(client):
|
||||
assert "blocked" in job["error"].lower() or "remote" in job["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding C2 (regression): local data: references remain permitted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_local_data_uri_image_succeeds(client):
|
||||
"""A local `data:` URI resolves through WeasyPrint's default loader and the
|
||||
conversion completes. Regression guard: must not regress to returning None
|
||||
@@ -103,9 +107,9 @@ def test_local_data_uri_image_succeeds(client):
|
||||
# Finding C1 (regression): decompression-bomb guard on zip extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
# Two independent mechanisms are exercised:
|
||||
# * the SHARED streaming guard common._copy_stream_with_cap (proven by the
|
||||
# unit tests: it counts WRITTEN decompressed bytes and aborts mid-stream
|
||||
# past the cap), and
|
||||
# * the SHARED streaming guard common._copy_stream_with_cap (proven by the unit
|
||||
# tests: it counts WRITTEN decompressed bytes and aborts mid-stream past the
|
||||
# cap), and
|
||||
# * app.py's zip-bomb guard end-to-end (proven by the e2e: a genuinely large
|
||||
# member trips the per-member size cap and the job fails with 'zip-bomb').
|
||||
|
||||
@@ -172,12 +176,11 @@ def test_c1_zip_bomb_guard_rejects_large_member(client, fixture_epub_bytes, monk
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding C3 (regression): stored-XSS stripping in rendered HTML/SVG
|
||||
# ---------------------------------------------------------------------------
|
||||
# WeasyPrint already drops <script> content and event-handler attribute VALUES from
|
||||
# rendered PDF *text* (confirmed empirically), so 'script absent from the PDF' is NOT
|
||||
# by itself a proof the guard works. The AUTHORITATIVE proof is the C3 unit test on
|
||||
# common._strip_scripts (it exercises the guard directly); the e2e below proves the
|
||||
# guard is wired into the render path without breaking the document (job succeeds and
|
||||
# visible content still renders). Satisfies: C3
|
||||
# WeasyPrint already drops <script> content and event-handler attribute VALUES
|
||||
# from rendered PDF *text*, so 'script absent from the PDF' is NOT by itself a
|
||||
# proof the guard works. The AUTHORITATIVE proof is the C3 unit test on
|
||||
# common._strip_scripts; the e2e below proves the guard is wired into the render
|
||||
# path without blanking the document.
|
||||
|
||||
|
||||
def test_strip_scripts_removes_all_vectors():
|
||||
@@ -198,9 +201,6 @@ def test_strip_scripts_removes_all_vectors():
|
||||
assert "EVIL_ATTR()" not in out
|
||||
assert "EVIL_JS()" not in out
|
||||
assert "EVIL_VB()" not in out
|
||||
assert "onmouseover" not in out.lower()
|
||||
assert "javascript:" not in out.lower()
|
||||
assert "vbscript:" not in out.lower()
|
||||
|
||||
|
||||
def test_strip_scripts_preserves_visible_and_safe_urls():
|
||||
@@ -258,4 +258,81 @@ def test_c3_svg_guard_wired_job_done(client):
|
||||
"</svg>"
|
||||
)
|
||||
job = _upload(client, svg, "xss.svg")
|
||||
assert job["status"] == "done", job
|
||||
assert job["status"] == "done", job
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding H1 (regression): CSV row/column render cap in _table_html
|
||||
# ---------------------------------------------------------------------------
|
||||
# A single oversized CSV must not render unbounded rows/columns on the single
|
||||
# worker thread (CPU/memory DoS). Rows/columns are TRUNCATED (not rejected) to
|
||||
# MAX_ROWS x MAX_COLS, and a visible note is appended so the truncation is honest.
|
||||
# Satisfies: H1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def csv_converter():
|
||||
from converters import csv_converter as C
|
||||
return C
|
||||
|
||||
|
||||
def test_table_html_caps_rows_to_max_rows(csv_converter):
|
||||
"""More than 5000 data rows are truncated to the first 5000 total rows
|
||||
(header included); a visible note is added.
|
||||
Satisfies: H1
|
||||
"""
|
||||
# header + 5001 body rows = 5002 non-empty rows total.
|
||||
body_rows = [["r" + str(i)] for i in range(5001)]
|
||||
rows = [["h"]] + body_rows
|
||||
out = csv_converter._table_html(rows)
|
||||
# rows[:5000] keeps the first 5000 total rows: 1 header + 4999 body = 5000 <tr>.
|
||||
assert out.count("<tr>") == 5000, out.count("<tr>")
|
||||
assert csv_converter._TRUNCATION_NOTE in out
|
||||
|
||||
|
||||
def test_table_html_caps_cols_to_max_cols(csv_converter):
|
||||
"""More than 50 columns are truncated to exactly 50 in header and body.
|
||||
Satisfies: H1
|
||||
"""
|
||||
# 60-column header + one 60-column body row.
|
||||
wide = ["c" + str(i) for i in range(60)]
|
||||
out = csv_converter._table_html([wide, wide])
|
||||
header_cells = out.split("</tr>")[0].count("<td>") + out.split("</tr>")[0].count("<th>")
|
||||
assert header_cells == 50, header_cells
|
||||
body_cells = out.split("</tr>")[-2].count("<td>")
|
||||
assert body_cells == 50, body_cells
|
||||
assert csv_converter._TRUNCATION_NOTE not in out
|
||||
|
||||
|
||||
def test_table_html_undersizes_are_not_truncated(csv_converter):
|
||||
"""A small table renders in full with no truncation note.
|
||||
Satisfies: H1
|
||||
"""
|
||||
rows = [["h1", "h2", "h3"], ["a", "b", "c"]]
|
||||
out = csv_converter._table_html(rows)
|
||||
assert out.count("<tr>") == 2
|
||||
assert "truncated to 5000 rows x 50 cols" not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding M2 (regression): stale output-file sweep
|
||||
# ---------------------------------------------------------------------------
|
||||
# A symmetric counterpart to _sweep_stale_work_dirs: output files older than the
|
||||
# same window are removed at startup, fresh files are kept.
|
||||
|
||||
|
||||
def test_m2_output_sweep_removes_only_stale_output_files(tmp_path, monkeypatch):
|
||||
"""_sweep_stale_output_files removes OUTPUT_DIR files older than the window,
|
||||
keeping fresh ones. Symmetric to the work-dir sweep test.
|
||||
Satisfies: M2
|
||||
"""
|
||||
monkeypatch.setattr(app, "OUTPUT_DIR", tmp_path)
|
||||
stale = tmp_path / "stale.pdf"
|
||||
stale.write_bytes(b"%PDF-1.7 old\n")
|
||||
fresh = tmp_path / "fresh.pdf"
|
||||
fresh.write_bytes(b"%PDF-1.7 new\n")
|
||||
old = time.time() - 7 * 3600 # 7h > 6h window
|
||||
os.utime(stale, (old, old))
|
||||
app._sweep_stale_output_files()
|
||||
assert not stale.exists(), "stale output file must be swept"
|
||||
assert fresh.exists(), "fresh output file must be kept"
|
||||
Reference in New Issue
Block a user