fix(security): isolate template rendering with hard resource limits
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -47,6 +48,43 @@ class TemplateRenderLimitTests(unittest.TestCase):
|
||||
with self.assertRaises(TemplateRenderError):
|
||||
rendering._render_payload(definition, revision, request=request, items=[{}])
|
||||
|
||||
def test_fresh_worker_preserves_exact_text_bytes_and_page_count(self):
|
||||
definition = SimpleNamespace(name="Example")
|
||||
revision = SimpleNamespace(content_html=None, content_text="Hi {{name}}: {{flag}}", template_type="serial_letter", layout={}, output_profiles=[])
|
||||
request = SimpleNamespace(output_format="text", parameters={"flag": True})
|
||||
with patch.object(rendering, "_render_payload_content", side_effect=AssertionError("parent renderer ran")):
|
||||
result = rendering._render_payload(definition, revision, request=request, items=[{"name": "Äda"}, {"name": "Grace"}])
|
||||
self.assertEqual(("Hi Äda: true\n\n---\n\nHi Grace: true".encode(), "text/plain; charset=utf-8", 2), result)
|
||||
|
||||
def test_real_worker_timeout_fails_without_parent_fallback(self):
|
||||
definition = SimpleNamespace(name="Example")
|
||||
revision = SimpleNamespace(content_html=None, content_text="Hello", template_type="serial_letter", layout={}, output_profiles=[])
|
||||
request = SimpleNamespace(output_format="text", parameters={})
|
||||
limits = replace(rendering.RENDER_PROCESS_LIMITS, wall_seconds=0.001)
|
||||
with patch.object(rendering, "RENDER_PROCESS_LIMITS", limits), patch.object(
|
||||
rendering, "_render_payload_content", side_effect=AssertionError("parent renderer ran")
|
||||
):
|
||||
with self.assertRaisesRegex(TemplateRenderError, "timeout"):
|
||||
rendering._render_payload(definition, revision, request=request, items=[{}])
|
||||
|
||||
def test_worker_html_bytes_match_existing_composition(self):
|
||||
definition = SimpleNamespace(name="Letters <archive>")
|
||||
revision = SimpleNamespace(content_html="<p>{{name}}: {{settings}}</p>", content_text=None, template_type="label_sheet", layout={"page_size": "A5", "columns": 2, "rows": 2}, output_profiles=[])
|
||||
request = SimpleNamespace(output_format="html", parameters={"settings": {"b": True, "a": "ü"}})
|
||||
items = [{"name": "<Ada>"}, {"name": "Grace & Co"}]
|
||||
expected = rendering._render_payload_content(definition, revision, request=request, items=items)
|
||||
actual = rendering._render_payload(definition, revision, request=request, items=items)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_real_worker_output_transport_limit_is_enforced(self):
|
||||
definition = SimpleNamespace(name="Example")
|
||||
revision = SimpleNamespace(content_html=None, content_text="x" * 4096, template_type="serial_letter", layout={}, output_profiles=[])
|
||||
request = SimpleNamespace(output_format="text", parameters={})
|
||||
limits = replace(rendering.RENDER_PROCESS_LIMITS, output_bytes=1024)
|
||||
with patch.object(rendering, "RENDER_PROCESS_LIMITS", limits):
|
||||
with self.assertRaisesRegex(TemplateRenderError, "output_limit"):
|
||||
rendering._render_payload(definition, revision, request=request, items=[{}])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+31
-1
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
@@ -19,6 +20,7 @@ from govoplan_core.core.templates import (
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_templates.backend import rendering
|
||||
from govoplan_templates.backend.capabilities import (
|
||||
SqlTemplateCatalog,
|
||||
SqlTemplateContentLibrary,
|
||||
@@ -231,7 +233,8 @@ class TemplateServiceTests(unittest.TestCase):
|
||||
idempotency_key="campaign-1:postal-output-1",
|
||||
)
|
||||
first = render_template(session, principal(), registry=_Registry(), request=request)
|
||||
second = render_template(session, principal(), registry=_Registry(), request=request)
|
||||
with patch.object(rendering, "run_bounded_operation", side_effect=AssertionError("idempotent render started a worker")):
|
||||
second = render_template(session, principal(), registry=_Registry(), request=request)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(first.render_id, second.render_id)
|
||||
@@ -243,6 +246,33 @@ class TemplateServiceTests(unittest.TestCase):
|
||||
self.assertIn(b"Ada", first.payload)
|
||||
self.assertIn(b"Grace", first.payload)
|
||||
|
||||
def test_real_worker_timeout_writes_neither_artifact_nor_render_evidence(self) -> None:
|
||||
store = _ArtifactStore()
|
||||
with self.database.session() as session:
|
||||
item, _ = create_template(session, principal(), payload())
|
||||
request = TemplateRenderRequest(
|
||||
template_id=item.id,
|
||||
usage="campaign.postal",
|
||||
items=({"name": "Ada", "postal": {"address": "Street 1"}},),
|
||||
persist_to_files=True,
|
||||
)
|
||||
limits = replace(rendering.RENDER_PROCESS_LIMITS, wall_seconds=0.001)
|
||||
with patch.object(rendering, "RENDER_PROCESS_LIMITS", limits):
|
||||
with self.assertRaisesRegex(TemplateRenderError, "timeout"):
|
||||
render_template(session, principal(), registry=_Registry(store), request=request)
|
||||
self.assertIsNone(store.request)
|
||||
self.assertEqual([], list_renders(session, principal()))
|
||||
|
||||
def test_incompatible_input_is_rejected_before_worker_admission(self) -> None:
|
||||
with self.database.session() as session:
|
||||
item, _ = create_template(session, principal(), payload())
|
||||
request = TemplateRenderRequest(
|
||||
template_id=item.id, usage="campaign.postal", items=({"name": "Missing address"},),
|
||||
)
|
||||
with patch.object(rendering, "run_bounded_operation", side_effect=AssertionError("incompatible render started a worker")):
|
||||
with self.assertRaises(TemplateCompatibilityError):
|
||||
render_template(session, principal(), registry=_Registry(), request=request)
|
||||
|
||||
def test_bounded_render_history_and_payload_are_owner_scoped(self) -> None:
|
||||
owner = principal(admin=False)
|
||||
other = principal(account_id="account-2", admin=False)
|
||||
|
||||
Reference in New Issue
Block a user