fix(security): isolate template rendering with hard resource limits

This commit is contained in:
2026-09-08 07:47:18 +02:00
parent 1f61464fd6
commit 1b1e3e0f92
6 changed files with 229 additions and 1 deletions
@@ -25,6 +25,12 @@ from govoplan_core.core.templates import (
TemplateRenderRequest,
TemplateRenderResult,
)
from govoplan_core.security.bounded_process import (
ProcessBudgetError,
ProcessLimits,
run_bounded_operation,
)
from govoplan_core.security.worker_payload import decode_worker_payload, encode_worker_payload
from govoplan_templates.backend.db.models import (
TemplateDefinition,
TemplateRender,
@@ -42,9 +48,36 @@ from govoplan_templates.backend.service import (
RENDERER_VERSION = "templates-html-1"
MAX_OUTPUT_BYTES = 5 * 1024 * 1024
MAX_ITEMS = 5_000
RENDER_PROCESS_LIMITS = ProcessLimits(
wall_seconds=15,
cpu_seconds=10,
memory_bytes=512 * 1024 * 1024,
input_bytes=32 * 1024 * 1024,
output_bytes=8 * 1024 * 1024,
)
_TOKEN_PATTERN = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
@dataclasses.dataclass(frozen=True, slots=True)
class _RenderDefinition:
name: str
@dataclasses.dataclass(frozen=True, slots=True)
class _RenderRevision:
content_text: str | None
content_html: str | None
template_type: str
layout: dict[str, object]
output_profiles: list[dict[str, object]]
@dataclasses.dataclass(frozen=True, slots=True)
class _RenderRequest:
output_format: str
parameters: dict[str, object]
class SqlTemplateRenderer:
def __init__(self, registry: object | None = None) -> None:
self.registry = registry
@@ -359,6 +392,68 @@ def _render_payload(
*,
request: TemplateRenderRequest,
items: Sequence[Mapping[str, object]],
) -> tuple[bytes, str, int]:
# Authoritative selection/idempotency precede this call. Transfer only the
# fields needed for pure rendering, never ORM objects, principals or Files.
try:
data = encode_worker_payload({
"definition": {"name": definition.name},
"revision": {
"content_text": revision.content_text,
"content_html": revision.content_html,
"template_type": revision.template_type,
"layout": dict(revision.layout),
"output_profiles": list(revision.output_profiles),
},
"request": {
"output_format": request.output_format,
"parameters": dict(request.parameters),
},
"items": tuple(dict(item) for item in items),
}, max_bytes=RENDER_PROCESS_LIMITS.input_bytes)
result = decode_worker_payload(
run_bounded_operation(_render_payload_worker, data, limits=RENDER_PROCESS_LIMITS),
max_bytes=RENDER_PROCESS_LIMITS.output_bytes,
)
except ProcessBudgetError as exc:
raise TemplateRenderError(f"Template rendering failed ({exc.code}): {exc}") from exc
except (TypeError, ValueError, RecursionError) as exc:
raise TemplateRenderError("Template input or output could not be safely transferred.") from exc
if not isinstance(result, dict):
raise TemplateRenderError("Template worker returned an invalid result.")
if "render_error" in result:
raise TemplateRenderError(str(result["render_error"]))
payload, content_type, page_count = result.get("payload"), result.get("content_type"), result.get("page_count")
if not isinstance(payload, bytes) or not isinstance(content_type, str) or type(page_count) is not int:
raise TemplateRenderError("Template worker returned an invalid result.")
if len(payload) > MAX_OUTPUT_BYTES:
_output_limit_exceeded()
return payload, content_type, page_count
def _render_payload_worker(payload: bytes) -> bytes:
data = decode_worker_payload(payload, max_bytes=RENDER_PROCESS_LIMITS.input_bytes)
definition = _RenderDefinition(**data["definition"])
revision = _RenderRevision(**data["revision"])
request = _RenderRequest(**data["request"])
try:
payload, content_type, page_count = _render_payload_content(
definition, revision, request=request, items=data["items"]
)
except TemplateRenderError as exc:
return encode_worker_payload({"render_error": str(exc)})
return encode_worker_payload(
{"payload": payload, "content_type": content_type, "page_count": page_count},
max_bytes=RENDER_PROCESS_LIMITS.output_bytes,
)
def _render_payload_content(
definition: _RenderDefinition,
revision: _RenderRevision,
*,
request: _RenderRequest,
items: Sequence[Mapping[str, object]],
) -> tuple[bytes, str, int]:
if request.output_format == "text":
body = revision.content_text or _html_to_text(revision.content_html or "")