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
2 changes: 2 additions & 0 deletions app/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from app.workers import start_workers, stop_workers
from apps.utils.docker.observe import shutdown_stats_pool
from apps.utils.docker.render_budget import shutdown_render_pool

logger = logging.getLogger("greffer")

Expand Down Expand Up @@ -62,3 +63,4 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await stop_workers(tasks)
finally:
shutdown_stats_pool()
shutdown_render_pool()
49 changes: 49 additions & 0 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,55 @@ def _heartbeat_interval_in_range(cls, v):
# allowlist.
greffer_compose_extra_allowed_keys: str = ""

# compose-containment Feature 1b (render budget). Sizes in BYTES, the
# timeout in seconds; 0 disables that dimension. The memory cap is what
# protects the shared controller from compile-time allocation
# blow-ups — disabling it on a multi-tenant node is the operator
# choosing the risk. render_budget.py reads the env directly (the
# log-rotation precedent); these fields document and validate.
greffer_render_max_input: int = 1048576
greffer_render_max_output: int = 4194304
greffer_render_mem_bytes: int = 268435456
greffer_render_timeout_seconds: float = 5.0

@field_validator("greffer_render_max_input", mode="before")
@classmethod
def _coerce_render_input(cls, v):
try:
n = int(v)
except (TypeError, ValueError):
return 1048576
return max(n, 0)

@field_validator("greffer_render_max_output", mode="before")
@classmethod
def _coerce_render_output(cls, v):
try:
n = int(v)
except (TypeError, ValueError):
return 4194304
return max(n, 0)

@field_validator("greffer_render_mem_bytes", mode="before")
@classmethod
def _coerce_render_mem(cls, v):
try:
n = int(v)
except (TypeError, ValueError):
return 268435456
return max(n, 0)

@field_validator("greffer_render_timeout_seconds", mode="before")
@classmethod
def _coerce_render_timeout(cls, v):
try:
n = float(v)
except (TypeError, ValueError):
return 5.0
if not math.isfinite(n):
return 5.0 # inf breaks Future.result; nan times out instantly
return max(n, 0.0)

@field_validator("greffer_instance_log_max_file", mode="before")
@classmethod
def _coerce_log_max_file(cls, v):
Expand Down
106 changes: 28 additions & 78 deletions apps/utils/docker/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
import json
import logging
from datauri import DataURI
from jinja2 import StrictUndefined
from jinja2.exceptions import SecurityError, TemplateError, UndefinedError
from jinja2.sandbox import ImmutableSandboxedEnvironment
import docker
import subprocess
import os
Expand All @@ -19,84 +17,24 @@
# calls.
client = docker.from_env(max_pool_size=32)

from apps.utils.docker.render_budget import (
RenderBudgetExceeded,
render_bounded,
)
from apps.utils.docker.volume import docker_copy_file_into_volume, docker_create_volume, docker_is_volume_exist

logger = logging.getLogger(__name__)

# Strict Jinja environment for rendering baked `file`/`json` destination
# contents (feature: baked-config-files). Unlike the lenient ``Template``
# used for the compose body, a missing/typo'd variable here MUST raise
# rather than render to empty string: a baked Keycloak realm with an empty
# ``{{ config.OIDC_RP_CLIENT_SECRET }}`` is a silent security failure, so we
# fail the deploy loudly instead. ``autoescape=False`` because these are
# config files (JSON/conf), not HTML, and we must not HTML-escape values.
#
# SANDBOXED: the template string is catalog-author-controlled, and the catalog
# is community-extensible, so we must assume it can be hostile. A plain
# Environment would allow server-side template injection -> RCE on this worker
# (which holds the instance's minted secrets, the greffer's manager token, and
# Docker socket access), e.g.
# ``{{ cycler.__init__.__globals__.os.popen('id').read() }}``. SandboxedEnvironment
# blocks attribute traversal to unsafe objects while still rendering the
# legitimate ``{{ config.X }}`` / ``{{ instance_url }}`` references. A
# SecurityError surfaces as a clean ConfigRenderError (-> 422), like any other
# render failure.
#
# StrictUndefined makes a missing/typo'd variable raise rather than render empty
# (a baked secret silently becoming '' is a security failure). The catalog
# validator is a SEPARATE, author-facing layer that rejects StrictUndefined
# *bypass idioms* (``config.get('X')`` / ``| default``) and integration refs;
# it is NOT an SSTI gate — the sandbox is what stops injection.
# ``autoescape=False`` because these are config files (JSON/conf), not HTML.
_FILE_RENDER_ENV = ImmutableSandboxedEnvironment(
undefined=StrictUndefined, autoescape=False, keep_trailing_newline=True
) # globals hardened below, once _harden is defined

# The compose BODY render. Sandboxed for the same reason as the file render
# above, but with the LENIENT undefined the body has always had -- see the
# comment in ``create_compose``. Split from _FILE_RENDER_ENV so neither
# policy can be changed by accident while editing the other.
#
# IMMUTABLE, not merely sandboxed. A plain SandboxedEnvironment blocks the
# attribute walk to the interpreter but still permits mutating calls on the
# objects passed to ``render()`` -- and those are the LIVE deployment dicts.
# ``{{ volumes.update({"x": {"value": "/"}}) }}`` renders as empty output
# while adding a volume, and ``create_volumes_then_copy_files`` then runs
# ``docker container create -v /:/root`` (volume.py) and copies attacker
# content into the host filesystem. Blocking dunder traversal alone leaves
# that write primitive open, so both envs refuse mutation.
def _harden(env):
"""Drop Jinja's default globals that re-open what the sandbox closes.

ImmutableSandboxedEnvironment refuses a MUTATING call on an immutable
target -- but it decides that from the object the method is bound to, so
an UNBOUND call slips past: ``dict`` is a Jinja global, and
``{{ dict.update(volumes.x, {"value": "/"}) }}`` checks ``dict`` (the
class) rather than the live mapping, renders clean, and rewrites the
context. Verified: the bound form raises SecurityError while the unbound
form succeeded and set a volume's value to "/", which
create_volumes_then_copy_files then mounts as ``-v /:/root``.

``cycler``/``joiner``/``namespace`` are the documented first hops of the
classic escape chains, and ``lipsum``/``range`` are of no use to a compose
file. The catalog uses none of them -- verified across every pinned entry
-- so removing them costs nothing and shrinks the surface to what a
compose actually needs.

What this does NOT do is bound resource use. Removing ``range`` closes
one way to allocate, not the class: ``*`` and ``**`` are not intercepted,
so ``{{ "A" * (10 ** 8) }}`` is sandbox-legal and renders a 100 MB
compose. The sandbox stops code execution; an input/output/time budget
around the render is the second half of Feature 1 in the root
docs/features/compose-containment/epic.md, scoped there to the custom
(untrusted) compose path."""
for unsafe in ('dict', 'range', 'lipsum', 'cycler', 'joiner', 'namespace'):
env.globals.pop(unsafe, None)
return env


_COMPOSE_RENDER_ENV = _harden(ImmutableSandboxedEnvironment(autoescape=False))
_harden(_FILE_RENDER_ENV)
# The hardened render environments moved to render_budget.py (compose-
# containment Feature 1b) so the render CHILD — which imports that module —
# never imports the docker client (on spawn platforms compose.py's module
# scope performs a live daemon handshake). Re-exported here for the existing
# call sites and tests.
from apps.utils.docker.render_budget import ( # noqa: F401 (re-export)
_COMPOSE_RENDER_ENV,
_FILE_RENDER_ENV,
render_json_bounded,
)


class ConfigRenderError(Exception):
Expand Down Expand Up @@ -316,7 +254,10 @@ def _render_baked_file(raw, greffon_info, dest_name):
explicitly for clarity)."""
try:
text = raw.decode('utf-8') if isinstance(raw, bytes) else raw
return _FILE_RENDER_ENV.from_string(text).render(**greffon_info)
# Feature 1b: budgeted like the body render. RenderBudgetExceeded is
# a ValueError, so the tuple below already converts it to the same
# clean ConfigRenderError 422.
return render_bounded('file', text, greffon_info)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply caps to the complete JSON destination

For an x-greffon-render JSON destination, _render_json_value invokes this bounded render independently for every string leaf, so each leaf receives a fresh input, output, and timeout budget. A small JSON array containing 100 templates that each expand just under the 4 MiB cap therefore accumulates roughly 400 MiB in the controller before json.dumps duplicates it, bypassing the protection and potentially OOM-killing the process. Track a shared budget/deadline across all leaves or cap the complete serialized destination before retaining it in the parent.

Useful? React with 👍 / 👎.

except (UndefinedError, TemplateError, SecurityError, UnicodeDecodeError,
TypeError, ValueError) as exc:
# TypeError/ValueError: e.g. `{{ x | tojson }}` on an undefined x raises
Expand All @@ -332,6 +273,10 @@ def _render_json_value(value, greffon_info, dest_name):
caller ``json.dumps`` the result). Rendering leaves rather than the
serialized string means ``json.dumps`` escapes the substituted values, so a
minted secret containing a quote or backslash can't corrupt the JSON."""
if value is not None and not isinstance(value, (str,)):
# One child round-trip for the whole tree (per-leaf submission was
# quadratic: every leaf re-pickled the full context).
return render_json_bounded(value, greffon_info)
if isinstance(value, str):
return _render_baked_file(value, greffon_info, dest_name)
if isinstance(value, dict):
Expand Down Expand Up @@ -715,7 +660,12 @@ def create_compose(compose, greffon_info):
# reachable. The compose file is only written after the render succeeds.
dumped = yaml.dump(compose)
try:
compose_file = _COMPOSE_RENDER_ENV.from_string(dumped).render(**greffon_info)
# Feature 1b: the render runs under the budget (input/output caps,
# child-process memory + time bound) — see render_budget.py. A cap
# violation re-raises below as its own bounded message.
compose_file = render_bounded('body', dumped, greffon_info)
except RenderBudgetExceeded as exc:
raise ConfigRenderError(str(exc)) from exc
except Exception as exc:
logger.error("compose render failed for %s: %s: %s",
greffon_info.get('id'), type(exc).__name__, exc)
Expand Down
Loading
Loading