fix(security): isolate template rendering with hard resource limits
This commit is contained in:
@@ -102,6 +102,43 @@ ROLE_TEMPLATES = (
|
||||
)
|
||||
|
||||
DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id="templates.render-worker-limits",
|
||||
title="Rendering resource and capacity limits",
|
||||
summary="Render in a disposable process and reject incomplete output before persistence.",
|
||||
body=(
|
||||
"Template substitution and document composition run in a fresh process with a 15-second wall limit, 10 CPU seconds, "
|
||||
"512 MiB address space, no file output, 32 MiB typed input and 8 MiB typed result transport. The existing final output "
|
||||
"limit remains 5 MiB and the item limit remains 5,000. Transport permits only explicit data types, at most 64 nesting "
|
||||
"levels and 1,000,000 value nodes. Exceeding a limit fails the entire render before Files or render evidence is written; "
|
||||
"reduce the input or template complexity and retry. Core GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY limits simultaneous work per "
|
||||
"API/worker process without queuing; a busy error can be retried later. Missing POSIX resource controls, cancellation "
|
||||
"and worker failures are explicit sanitized render errors, with no in-process fallback. Authorization, revision "
|
||||
"selection, idempotency lookup, SQL sessions and artifact persistence remain in the parent. Existing completed "
|
||||
"idempotent output is reused before starting a worker. Operators must budget aggregate memory across process slots."
|
||||
),
|
||||
layer="static",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("user", "module_admin", "operator"),
|
||||
order=30,
|
||||
translations={"de": {
|
||||
"title": "Ressourcen- und Kapazitätsgrenzen beim Rendern",
|
||||
"summary": "In einem kurzlebigen Prozess rendern und unvollständige Ausgaben vor dem Speichern ablehnen.",
|
||||
"body": (
|
||||
"Platzhalterersetzung und Dokumentaufbau laufen in einem neuen Prozess mit höchstens 15 Sekunden Gesamtdauer, "
|
||||
"10 CPU-Sekunden, 512 MiB Adressraum, keiner Dateiausgabe sowie 32 MiB typisierter Eingabe und 8 MiB Ergebnistransport. "
|
||||
"Die endgültige Ausgabe bleibt auf 5 MiB und die Elementzahl auf 5.000 begrenzt. Der Transport erlaubt nur ausdrückliche "
|
||||
"Datentypen, höchstens 64 Verschachtelungsebenen und 1.000.000 Wertknoten. Eine Grenzverletzung bricht den gesamten "
|
||||
"Vorgang vor dem Speichern in Files oder als Render-Nachweis ab; reduzieren Sie Eingaben oder Vorlagenkomplexität "
|
||||
"und versuchen Sie es erneut. Core GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY begrenzt gleichzeitige Arbeiten pro API-/Worker-Prozess "
|
||||
"ohne Warteschlange; bei belegter Kapazität kann später erneut versucht werden. Fehlende POSIX-Ressourcenbegrenzungen, "
|
||||
"Abbruch und Worker-Fehler sind ausdrückliche bereinigte Render-Fehler ohne Rückfall auf den Elternprozess. "
|
||||
"Berechtigungsprüfung, Revisionsauswahl, Idempotenzprüfung, SQL-Sitzungen und Artefaktspeicherung bleiben im Elternprozess. "
|
||||
"Bereits abgeschlossene idempotente Ausgaben werden vor einem Worker-Start wiederverwendet. Betreiber müssen den "
|
||||
"Gesamtspeicher aller Prozessplätze berücksichtigen."
|
||||
),
|
||||
}},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="templates.workspace-layout",
|
||||
title="Templates workspace actions",
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
Reference in New Issue
Block a user