Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ jobs:
run: pip install --require-hashes -r requirements-dev.lock.txt

- name: Run ruff check
run: ruff check backend/
run: ruff check backend/ installers/windows/build.py installers/windows/tests/

- name: Run ruff format check
run: ruff format --check backend/
run: ruff format --check backend/ installers/windows/build.py installers/windows/tests/

- name: Test Windows PDF runtime packaging
run: python -m pytest installers/windows/tests/ --no-cov -q

- name: Run source size budget
run: python tools/check_source_size_budget.py
Expand Down Expand Up @@ -309,7 +312,7 @@ jobs:
working-directory: frontend
# V8 coverage instrumentation makes the largest jsdom integration
# tests slower on shared runners; keep the normal 10s limit unchanged.
run: npm run test:coverage -- --testTimeout=20000 && npm run check:i18n
run: npm run test:coverage -- --testTimeout=20000 && npm run check:i18n && npm run check:mocker-security

frontend-build:
name: Frontend Build
Expand Down
2 changes: 1 addition & 1 deletion backend/app/resources/pdf/runtime-manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"renderer": {
"weasyprint": "69.0",
"weasyprint": "70.0",
"pikepdf": "10.10.0",
"fonttools": "4.63.0",
"jinja2": "3.1.6"
Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/document_layout_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from backend.app.services.document_catalog import DocumentType

RENDERER_VERSION = "weasyprint-69.0+pikepdf-10.10.0"
RENDERER_VERSION = "weasyprint-70.0+pikepdf-10.10.0"
VALIDATOR_VERSION = "verapdf-1.30.2"
SUPPORTED_LANGUAGES = ("de", "en")
SUPPORTED_DOCUMENT_TYPES = tuple(document_type.value for document_type in DocumentType)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/pdfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

_ICC_PACKAGE = "backend.app.resources.pdf"
_ICC_FILENAME = "sRGB.icc"
_PRODUCER = "PrintOps document renderer / WeasyPrint 69.0 / pikepdf 10.10.0"
_PRODUCER = "PrintOps document renderer / WeasyPrint 70.0 / pikepdf 10.10.0"
_BOX_TOLERANCE_PT = 0.1
_RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
_PDFA_EXTENSION_NS = "http://www.aiim.org/pdfa/ns/extension/"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"template_version": "1.0.0",
"renderer_version": "weasyprint-69.0+pikepdf-10.10.0",
"renderer_version": "weasyprint-70.0+pikepdf-10.10.0",
"validator_version": "verapdf-1.30.2",
"page": {
"template_key": "classic",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"template_version": "1.0.0",
"renderer_version": "weasyprint-69.0+pikepdf-10.10.0",
"renderer_version": "weasyprint-70.0+pikepdf-10.10.0",
"validator_version": "verapdf-1.30.2",
"page": {
"template_key": "modern",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ def test_ten_page_document_renders_within_release_budget(tmp_path):
if not WEASYPRINT.exists():
pytest.skip("pinned WeasyPrint runtime is not staged")
sample = load_sample("invoice-de-standard")
expanded = sample.model_copy(update={"lines": sample.lines * 14})
# Keep a >=10-page workload with v70's denser pagination; do not relax
# either the minimum page count or the ten-second release budget.
expanded = sample.model_copy(update={"lines": sample.lines * 16})
renderer = DocumentRenderer(
engine_cli=WEASYPRINT,
cache_dir=tmp_path / "cache",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ def test_ten_page_document_and_long_position_render_within_hard_limit(tmp_path):
"description": f"Position {number}: {base.description}",
}
)
for number in range(1, 81)
# v70's Pango/layout stack fits 80 positions on nine pages. Ninety
# positions retain this test's ten-page workload (not a golden layout).
for number in range(1, 91)
)
request = _request("compact")
request = RenderInput(
Expand Down
69 changes: 69 additions & 0 deletions backend/tests/integration/test_weasyprint_fetcher_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""CVE-2026-55073: auxiliary resources must retain the original fetch policy.

Only test-owned temporary files are used; no external service is contacted.
Explicit trusted filename/file-object inputs are not URL policy inputs.
"""

from __future__ import annotations

import pytest


@pytest.fixture
def resources(tmp_path):
from weasyprint import HTML
from weasyprint.urls import FatalURLFetchingError, URLFetcher, URLFetcherResponse

blocked = tmp_path / "blocked.css"
blocked.write_text("@page { size: 1234px 5678px }", encoding="utf-8")
outer = tmp_path / "outer.css"
outer.write_text(f'@import url("{blocked.as_uri()}");', encoding="utf-8")
metadata = tmp_path / "metadata.xmp"
marker = b"printops-owned-metadata-canary"
metadata.write_bytes(
b'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
b'<rdf:Description xmlns:fixture="urn:printops:test" fixture:marker="' + marker + b'"/></rdf:RDF>'
)

class Policy(URLFetcher):
def __init__(self, allowed=()):
super().__init__()
self.allowed = set(allowed)
self.calls = []

def fetch(self, url, headers=None):
self.calls.append(url)
for path in (blocked, outer, metadata):
if url == path.as_uri() and url in self.allowed:
mime = "text/css" if path.suffix == ".css" else "application/rdf+xml"
return URLFetcherResponse(url, path.read_bytes(), {"Content-Type": mime})
raise FatalURLFetchingError("fixture resource denied by original fetcher")

return HTML, Policy, FatalURLFetchingError, blocked, outer, metadata, marker


@pytest.mark.parametrize("channel", ["html-link", "stylesheet", "import", "xmp"])
def test_auxiliary_resources_cannot_bypass_original_fetcher(resources, channel):
HTML, Policy, Denied, blocked, outer, metadata, _ = resources
policy = Policy([outer.as_uri()] if channel == "import" else [])
source = f'<link rel="stylesheet" href="{blocked.as_uri()}">' if channel == "html-link" else ""
html = HTML(string=f"{source}<p>Control document</p>", url_fetcher=policy)
with pytest.raises(Denied, match="original fetcher"):
if channel == "xmp":
html.write_pdf(xmp_metadata=[metadata.as_uri()], pdf_variant="pdf/a-3b")
elif channel in {"stylesheet", "import"}:
html.render(stylesheets=[(outer if channel == "import" else blocked).as_uri()])
else:
html.render()
assert (metadata if channel == "xmp" else blocked).as_uri() in policy.calls


def test_original_fetcher_still_allows_approved_stylesheets_and_metadata(resources):
HTML, Policy, _, blocked, _, metadata, marker = resources
policy = Policy([blocked.as_uri(), metadata.as_uri()])
html = HTML(string="<p>Allowed document</p>", url_fetcher=policy)
document = html.render(stylesheets=[blocked.as_uri()])
assert (document.pages[0].width, document.pages[0].height) == (1234, 5678)
pdf = document.write_pdf(xmp_metadata=[metadata.as_uri()], pdf_variant="pdf/a-3b", uncompressed_pdf=True)
assert marker in pdf
assert policy.calls == [blocked.as_uri(), metadata.as_uri()]
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_catalog_is_closed_complete_and_versioned():
assert set(PAGE_FORMATS_MM) == {"A4", "Letter"}
assert SUPPORTED_LANGUAGES == ("de", "en")
assert set(SUPPORTED_DOCUMENT_TYPES) == {document_type.value for document_type in DocumentType}
assert RENDERER_VERSION == "weasyprint-69.0+pikepdf-10.10.0"
assert RENDERER_VERSION == "weasyprint-70.0+pikepdf-10.10.0"
assert VALIDATOR_VERSION == "verapdf-1.30.2"
assert LAYOUT_SECTION_KEYS == (
"page",
Expand Down
13 changes: 12 additions & 1 deletion backend/tests/unit/services/test_pdf_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@


def test_python_pdf_packages_are_exactly_pinned() -> None:
assert version("weasyprint") == "69.0"
assert version("weasyprint") == "70.0"
assert version("pikepdf") == "10.10.0"
assert version("fonttools") == "4.63.0"

Expand All @@ -36,3 +36,14 @@ def test_srgb_output_intent_matches_manifest_receipt() -> None:
assert len(content) >= 3_000
assert hashlib.sha256(content).hexdigest() == manifest["srgb"]["sha256"]
assert manifest["srgb"]["color_space"] == "RGB"


def test_renderer_manifest_and_receipt_track_installed_runtime() -> None:
from backend.app.services.document_layout_catalog import RENDERER_VERSION

manifest = json.loads((RESOURCE_DIR / "runtime-manifest.json").read_text(encoding="utf-8"))
for package, pinned in manifest["renderer"].items():
assert version(package) == pinned
assert f"weasyprint-{version('weasyprint')}+pikepdf-{version('pikepdf')}" == RENDERER_VERSION
key = RESOURCE_DIR / manifest["verapdf"]["signing_key"]
assert hashlib.sha256(key.read_bytes()).hexdigest() == manifest["verapdf"]["signing_key_sha256"]
73 changes: 73 additions & 0 deletions docs/security-alert-triage-2026-09-11.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Dependabot remediation — 2026-09-11

Tracking: [issue #177](https://github.com/ichwars/PrintOps/issues/177).
All five alerts open at the start of this change are treated as actionable
dependency findings, not dismissed on the basis of limited application exposure.

| Alerts | Dependency / manifests | Vulnerable version | Fixed version |
| --- | --- | --- | --- |
| 21, 22 | `@vitest/mocker`, `vitest`; `frontend/package-lock.json` | 4.1.8 | 4.1.11 |
| 23, 24, 25 | `weasyprint`; `requirements.txt` and both Python lockfiles | 69.0 | 70.0 |

## Boundary and fix

[CVE-2026-84373](https://github.com/advisories/GHSA-82fw-gwwq-j7x9)
allows redirect mocks to bypass Vite's filesystem allow/deny policy. The complete
Vitest package family, including coverage, moves together to the patched v4
release. PrintOps uses jsdom tests, not the optional browser mocker server; this
limits exposure but is not a reason to retain the vulnerable dependency.

[CVE-2026-55073](https://github.com/advisories/GHSA-jf6q-chmf-3h3v)
allows auxiliary stylesheet and XMP URLs to bypass WeasyPrint's original fetcher.
[WeasyPrint 70.0](https://github.com/Kozea/WeasyPrint/releases/tag/v70.0)
threads that fetcher through both entry points, including nested CSS imports.
PrintOps continues using fixed CLI arguments, registered hash-verified assets,
file-only loading, and disabled HTTP redirects. Untrusted options, filenames,
file objects, or independently constructed CSS objects must not be forwarded to
WeasyPrint; explicit trusted file inputs are not URL-fetcher policy inputs.

## Runtime compatibility

- Python requirements and both hash-locked dependency graphs pin 70.0.
- Windows packaging uses the official `weasyprint-windows-onedir.zip`, verified
against SHA-256 `ab1151f210b4e6bb7aa7a79e91a67e8ddb760094c107bfda55241b6aaefe7d53`.
The complete runtime, including `_internal` native libraries, is staged at
the existing `runtime/weasyprint/dist/weasyprint.exe` path.
Portable packaging tests live under `installers/windows/tests/` and run in
backend-lint CI; they do not require installer sources in the Docker image.
- New render receipts, cache fingerprints, and PDF producer metadata identify
WeasyPrint 70.0. Previously issued artifacts and their immutable 69.0 receipts
are not rewritten. The append-only schema tests intentionally retain the
historical version in their fixtures.
- veraPDF, its signatures, ICC profiles, pikepdf, fonts, and validation policy
remain unchanged. A test now verifies the public signing key's canonical hash;
the existing `.gitattributes` LF rule remains its owner.
- The v70 Windows layout stack places the 80-position stress sample on nine
pages. Text extraction confirmed every position remains present. Ninety
positions produce ten pages with every position present, preserving the
existing ten-page workload test. The timed sample likewise grows from 14 to
16 repetitions without relaxing its page minimum or ten-second limit.
This is not a byte-for-byte cross-version
rendering promise; deterministic output is tested within the pinned runtime.

## Regression evidence

`npm run check:mocker-security` exercises the installed interceptor registration
and load hooks against Vite's real filesystem policy. Both a denied in-root
`.env` fixture and an out-of-root opaque-URL traversal were readable under 4.1.8
and are rejected under 4.1.11; an allowed redirect remains readable. The check
also runs in the required frontend test CI job.

`test_weasyprint_fetcher_security.py` exercises real rendering with owned local
canaries only: HTML-link control, stylesheet, nested import, XMP metadata, and
approved stylesheet/metadata controls. Under 69.0 the three auxiliary rejection
tests fail and the allow-control shows the configured fetcher was never called.
All five pass under 70.0. No internal service, credential, or unrelated user file
is accessed by these tests.

Release verification additionally covers PDF/A-3u with the pinned veraPDF CLI,
document types/languages/page formats/templates, hybrid e-invoices, deterministic
output, font/letterhead handling, Windows packaging, frontend tests/build, and
dependency audits. CI and the linked PR carry the final check results. Closure
requires GitHub to mark alerts 21–25 fixed after merging into the default branch;
no vulnerability dismissal is part of this change.
Loading