From a7dc917021212ba16d7a867537efc38c01517207 Mon Sep 17 00:00:00 2001 From: CoreRasurae Date: Sun, 22 Feb 2026 11:59:33 +0000 Subject: [PATCH] fix(security): harden template rendering ISSUES CLOSED: #319 --- benchmarks/security_template_bench.py | 87 +++++ docs/reference/template_security.md | 145 ++++++++ features/security_templates.feature | 205 +++++++++++ features/steps/security_template_steps.py | 243 ++++++++++++ robot/helper_security_templates.py | 108 ++++++ robot/security_templates.robot | 82 +++++ src/cleveragents/templates/__init__.py | 28 ++ src/cleveragents/templates/renderer.py | 46 ++- src/cleveragents/templates/secure_renderer.py | 346 ++++++++++++++++++ 9 files changed, 1284 insertions(+), 6 deletions(-) create mode 100644 benchmarks/security_template_bench.py create mode 100644 docs/reference/template_security.md create mode 100644 features/security_templates.feature create mode 100644 features/steps/security_template_steps.py create mode 100644 robot/helper_security_templates.py create mode 100644 robot/security_templates.robot create mode 100644 src/cleveragents/templates/__init__.py create mode 100644 src/cleveragents/templates/secure_renderer.py diff --git a/benchmarks/security_template_bench.py b/benchmarks/security_template_bench.py new file mode 100644 index 000000000..9f13958a5 --- /dev/null +++ b/benchmarks/security_template_bench.py @@ -0,0 +1,87 @@ +"""ASV benchmarks for secure template rendering. + +Measures the overhead introduced by security checks in the +template rendering pipeline. +""" + +from __future__ import annotations + +from cleveragents.templates.secure_renderer import ( + SecureTemplateRenderer, + TemplateConfig, + render_template_secure, + validate_template, +) + + +class RenderBench: + """Benchmark secure template rendering throughput.""" + + timeout = 60 + + def setup(self) -> None: + self.renderer = SecureTemplateRenderer( + config=TemplateConfig( + allowed_keys=frozenset({"name", "role", "project"}), + ), + ) + self.template = "Hello {name}, you are {role} on {project}" + self.context = { + "name": "Alice", + "role": "developer", + "project": "cleveragents", + } + + def time_render_simple(self) -> None: + """Benchmark rendering a simple template.""" + self.renderer.render(self.template, self.context) + + def time_render_no_placeholders(self) -> None: + """Benchmark rendering template with no placeholders.""" + self.renderer.render("Static text with no vars", {}) + + def time_render_many_placeholders(self) -> None: + """Benchmark rendering template with many placeholders.""" + tpl = " ".join(f"{{k{i}}}" for i in range(50)) + ctx = {f"k{i}": f"v{i}" for i in range(50)} + renderer = SecureTemplateRenderer(config=TemplateConfig()) + renderer.render(tpl, ctx) + + +class ValidateBench: + """Benchmark template validation throughput.""" + + timeout = 60 + + def setup(self) -> None: + self.safe_template = "Hello {name}, role={role}" + self.unsafe_template = "{obj.__class__.__init__.__globals__}" + + def time_validate_safe(self) -> None: + """Benchmark validating a safe template.""" + validate_template(self.safe_template) + + def time_validate_unsafe(self) -> None: + """Benchmark validating an unsafe template.""" + validate_template(self.unsafe_template) + + def time_validate_with_allowlist(self) -> None: + """Benchmark validating with an allowlist.""" + validate_template( + self.safe_template, + allowed_keys=frozenset({"name", "role"}), + ) + + +class ConvenienceFunctionBench: + """Benchmark the convenience render_template_secure function.""" + + timeout = 60 + + def time_convenience_render(self) -> None: + """Benchmark the convenience function.""" + render_template_secure( + "Hello {name}", + {"name": "World"}, + allowed_keys=frozenset({"name"}), + ) diff --git a/docs/reference/template_security.md b/docs/reference/template_security.md new file mode 100644 index 000000000..8935e40d9 --- /dev/null +++ b/docs/reference/template_security.md @@ -0,0 +1,145 @@ +# Template Security + +## Overview + +The `cleveragents.templates.secure_renderer` module provides a sandboxed +template renderer that prevents template injection attacks while +supporting simple variable substitution. + +## Threat Model + +Template injection occurs when user-controlled input is embedded in +template strings and evaluated. Python's `str.format()` is particularly +dangerous because it allows: + +- **Attribute access**: `{obj.__class__.__init__.__globals__}` can leak + sensitive module-level data. +- **Index access**: `{items[0]}` can read arbitrary collection entries. +- **Format specs**: `{val:>10}` can cause unexpected formatting or DoS. +- **Conversion flags**: `{val!r}` can change output representation. + +The secure renderer rejects all of these, only allowing `{name}` style +substitution with simple identifiers. + +## Allowed Syntax + +Only the following template syntax is permitted: + +``` +{variable_name} +``` + +Where `variable_name` matches `\w+` (letters, digits, underscore). + +Everything else is rejected: + +| Pattern | Risk | Result | +|------------------|-------------------------------|----------| +| `{x.y}` | Attribute access | Rejected | +| `{x[0]}` | Index access | Rejected | +| `{x:>10}` | Format spec | Rejected | +| `{x!r}` | Conversion flag | Rejected | +| `{fn()}` | Function call | Rejected | +| `{{ x }}` | Jinja2 expression | Rejected | +| `{% block %}` | Jinja2 block | Rejected | + +## Configuration + +Use `TemplateConfig` to configure the renderer: + +```python +from cleveragents.templates.secure_renderer import ( + SecureTemplateRenderer, + TemplateConfig, +) + +config = TemplateConfig( + allowed_keys=frozenset({"name", "description", "role"}), + max_template_length=10_000, # default + max_output_length=50_000, # default + reject_unknown_keys=True, # default + log_rejected=True, # default +) +renderer = SecureTemplateRenderer(config=config) +``` + +### Parameters + +- **`allowed_keys`**: Set of permitted placeholder names. Empty means + any simple identifier is allowed (open mode). +- **`max_template_length`**: Maximum raw template string length + (default: 10,000). Prevents DoS via oversized templates. +- **`max_output_length`**: Maximum rendered output length + (default: 50,000). Prevents DoS via value expansion. +- **`reject_unknown_keys`**: Whether to raise on keys not in the + allowlist (default: `True`). Ignored when `allowed_keys` is empty. +- **`log_rejected`**: Log rejected keys at WARNING level (default: + `True`). + +## Pre-validation + +Use `validate_template()` at action/plan creation time to catch +issues early: + +```python +from cleveragents.templates.secure_renderer import validate_template + +errors = validate_template( + template_text, + allowed_keys=frozenset({"name", "description"}), +) +if errors: + raise ValueError(f"Invalid template: {errors}") +``` + +## Convenience Function + +For one-off renders without creating a renderer instance: + +```python +from cleveragents.templates.secure_renderer import render_template_secure + +result = render_template_secure( + "Hello {name}", + {"name": "World"}, + allowed_keys=frozenset({"name"}), +) +``` + +## Safe Substitution Behaviour + +If a placeholder key is not found in the context, it is left as-is +in the output (safe-substitute behaviour): + +```python +renderer.render("Hello {name}, {role}", {"name": "Alice"}) +# => "Hello Alice, {role}" +``` + +## Legacy Compatibility + +The existing `TemplateRenderer` in `cleveragents.templates.renderer` +now wraps `SecureTemplateRenderer` internally. It returns the raw +template on any rendering failure to preserve backward compatibility, +and logs a WARNING when doing so (enable DEBUG for the full template). + +An optional `config` parameter allows callers to pass a +`TemplateConfig` through to the underlying secure renderer: + +```python +from cleveragents.templates.renderer import TemplateRenderer +from cleveragents.templates.secure_renderer import TemplateConfig + +renderer = TemplateRenderer( + config=TemplateConfig(allowed_keys=frozenset({"name"})), +) +``` + +## Exception Hierarchy + +``` +TemplateError (base) +├── TemplateSecurityError — unsafe constructs +├── TemplateSizeError — length limits exceeded +└── TemplateValidationError — allowlist violations +``` diff --git a/features/security_templates.feature b/features/security_templates.feature new file mode 100644 index 000000000..27741bf9c --- /dev/null +++ b/features/security_templates.feature @@ -0,0 +1,205 @@ +Feature: Secure template rendering + As a security-conscious developer + I want templates to be sandboxed and validated + So that template injection attacks are prevented + + Background: + Given a secure template test environment + + # -- Safe rendering ------------------------------------------------------- + + Scenario: Render a simple placeholder + Given the secure template text "{greeting} {name}" + And a secure template context key "greeting" with value "Hello" + And a secure template context key "name" with value "World" + When I render the secure template + Then the secure template output should be "Hello World" + + Scenario: Render with missing placeholder leaves it intact + Given the secure template text "Hello {name}, your role is {role}" + And a secure template context key "name" with value "Alice" + When I render the secure template + Then the secure template output should be "Hello Alice, your role is {role}" + + Scenario: Render with empty context returns template as-is + Given the secure template text "No placeholders here" + When I render the secure template + Then the secure template output should be "No placeholders here" + + Scenario: Render with numeric values converts to string + Given the secure template text "Count: {count}" + And a secure template context key "count" with value "42" + When I render the secure template + Then the secure template output should be "Count: 42" + + # -- Security rejection --------------------------------------------------- + + Scenario: Reject attribute access in template + Given the secure template text "{obj.__class__.__name__}" + When I render the secure template expecting a security error + Then the secure template error should mention "Unsafe" + + Scenario: Reject index access in template + Given the secure template text "{items[0]}" + When I render the secure template expecting a security error + Then the secure template error should mention "Unsafe" + + Scenario: Reject format spec in template + Given the secure template text "{value:>10}" + When I render the secure template expecting a security error + Then the secure template error should mention "Unsafe" + + Scenario: Reject conversion flag in template + Given the secure template text "{value!r}" + When I render the secure template expecting a security error + Then the secure template error should mention "Unsafe" + + Scenario: Reject function call in template + Given the secure template text "{fn()}" + When I render the secure template expecting a security error + Then the secure template error should mention "Unsafe" + + Scenario: Reject Jinja2 expression delimiters + Given the secure template text "{{ config }}" + When I render the secure template expecting a security error + Then the secure template error should mention "Unsafe" + + Scenario: Reject Jinja2 block delimiters + Given the secure template text "{% for x in items %}x{% endfor %}" + When I render the secure template expecting a security error + Then the secure template error should mention "Unsafe" + + # -- Size limits ----------------------------------------------------------- + + Scenario: Reject template exceeding max length + Given a secure template that is 11000 characters long + When I render the secure template expecting a size error + Then the secure template error should mention "exceeds maximum" + + Scenario: Reject output exceeding max length + Given the secure template text "{big}" + And a secure template config with max output length of 20 + And a secure template context key "big" with value "This value is definitely longer than twenty characters" + When I render the secure template expecting a size error + Then the secure template error should mention "exceeds maximum" + + # -- Allowlist enforcement ------------------------------------------------ + + Scenario: Reject template key not in allowlist + Given the secure template text "{name} {secret}" + And a secure template with allowed keys "name" + And a secure template context key "name" with value "Alice" + And a secure template context key "secret" with value "hidden" + When I render the secure template expecting a validation error + Then the secure template error should mention "disallowed" + + Scenario: Accept template key that is in allowlist + Given the secure template text "{name}" + And a secure template with allowed keys "name" + And a secure template context key "name" with value "Alice" + When I render the secure template + Then the secure template output should be "Alice" + + Scenario: Open allowlist allows any simple key + Given the secure template text "{anything}" + And the secure template has no key restrictions + And a secure template context key "anything" with value "works" + When I render the secure template + Then the secure template output should be "works" + + # -- Pre-validation ------------------------------------------------------- + + Scenario: Validate clean template returns no errors + Given the secure template text "{name} {role}" + When I validate the secure template + Then the secure template validation should be empty + + Scenario: Validate unsafe template returns errors + Given the secure template text "{obj.__class__}" + When I validate the secure template + Then the secure template validation should not be empty + And the secure template validation should mention "Unsafe" + + Scenario: Validate template with unknown keys returns errors + Given the secure template text "{name} {secret}" + And a secure template with allowed keys "name" + When I validate the secure template + Then the secure template validation should not be empty + And the secure template validation should mention "Unknown" + + Scenario: Validate oversized template returns errors + Given a secure template that is 11000 characters long + When I validate the secure template + Then the secure template validation should not be empty + And the secure template validation should mention "exceeds" + + # -- Convenience function ------------------------------------------------- + + Scenario: render_template_secure convenience function works + Given the secure template text "{greeting}" + And a secure template context key "greeting" with value "Hi" + When I render via the secure convenience function + Then the secure template output should be "Hi" + + Scenario: render_template_secure rejects unsafe input + Given the secure template text "{obj.attr}" + When I render via the secure convenience function expecting an error + Then the secure template error should mention "Unsafe" + + # -- Config property access ------------------------------------------------ + + Scenario: Renderer exposes active configuration + Given a secure template with allowed keys "name" + When I create a renderer with those settings + Then the renderer config allowed keys should contain "name" + + # -- Validate with matching allowlist (no unknown keys) ------------------- + + Scenario: Validate template where all keys match allowlist + Given the secure template text "{name}" + And a secure template with allowed keys "name" + When I validate the secure template + Then the secure template validation should be empty + + # -- Rejected keys without logging ---------------------------------------- + + Scenario: Reject unknown key without logging when log_rejected is false + Given the secure template text "{name} {secret}" + And a secure template config with logging disabled + And a secure template context key "name" with value "Alice" + And a secure template context key "secret" with value "hidden" + When I render the secure template expecting a validation error + Then the secure template error should mention "disallowed" + + # -- reject_unknown_keys=False path ---------------------------------------- + + Scenario: Allow unknown keys when reject_unknown_keys is disabled + Given the secure template text "{name} {extra}" + And a secure template config that permits unknown keys + And a secure template context key "name" with value "Alice" + And a secure template context key "extra" with value "data" + When I render the secure template + Then the secure template output should be "Alice data" + + # -- Legacy renderer with config passthrough -------------------------------- + + Scenario: Legacy TemplateRenderer accepts config passthrough + Given the secure template text "{name}" + And a secure template with allowed keys "name" + And a secure template context key "name" with value "Carol" + When I render via the legacy TemplateRenderer with config + Then the secure template output should be "Carol" + + # -- Legacy renderer backward compat -------------------------------------- + + Scenario: Legacy TemplateRenderer returns raw on unsafe template + Given the secure template text "{obj.__class__}" + And a secure template context key "obj" with value "test" + When I render via the legacy TemplateRenderer + Then the secure template output should be "{obj.__class__}" + + Scenario: Legacy TemplateRenderer renders safe templates + Given the secure template text "{name}" + And a secure template context key "name" with value "Bob" + When I render via the legacy TemplateRenderer + Then the secure template output should be "Bob" diff --git a/features/steps/security_template_steps.py b/features/steps/security_template_steps.py new file mode 100644 index 000000000..7cefff998 --- /dev/null +++ b/features/steps/security_template_steps.py @@ -0,0 +1,243 @@ +"""Step definitions for secure template rendering tests.""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.templates.renderer import TemplateRenderer +from cleveragents.templates.secure_renderer import ( + SecureTemplateRenderer, + TemplateConfig, + TemplateError, + TemplateSecurityError, + TemplateSizeError, + TemplateValidationError, + render_template_secure, + validate_template, +) + +__all__: list[str] = [] + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a secure template test environment") +def step_given_secure_template_env(context: Context) -> None: + context.sec_template = "" + context.sec_context = {} # dict[str, str] + context.sec_rendered = "" + context.sec_error = None # TemplateError | None + context.sec_validation_errors = [] # list[str] + context.sec_config = None # TemplateConfig | None + context.sec_allowed_keys = None # frozenset[str] | None + context.sec_renderer = None # SecureTemplateRenderer | None + + +# --------------------------------------------------------------------------- +# Given steps — template text +# --------------------------------------------------------------------------- + + +@given('the secure template text "{template}"') +def step_given_template(context: Context, template: str) -> None: + context.sec_template = template + + +@given('a secure template context key "{key}" with value "{value}"') +def step_given_context_key(context: Context, key: str, value: str) -> None: + context.sec_context[key] = value + + +@given('a secure template with allowed keys "{keys}"') +def step_given_allowed_keys(context: Context, keys: str) -> None: + context.sec_allowed_keys = frozenset(k.strip() for k in keys.split(",")) + + +@given("the secure template has no key restrictions") +def step_given_template_open(context: Context) -> None: + context.sec_allowed_keys = None + + +@given("a secure template that is {n} characters long") +def step_given_long_template(context: Context, n: str) -> None: + context.sec_template = "x" * int(n) + + +@given("a secure template config with max output length of {n}") +def step_given_max_output_config(context: Context, n: str) -> None: + context.sec_config = TemplateConfig(max_output_length=int(n)) + + +@given("a secure template config with logging disabled") +def step_given_config_logging_disabled(context: Context) -> None: + context.sec_config = TemplateConfig( + allowed_keys=context.sec_allowed_keys or frozenset({"name"}), + log_rejected=False, + ) + + +@given("a secure template config that permits unknown keys") +def step_given_config_permit_unknown(context: Context) -> None: + context.sec_config = TemplateConfig( + allowed_keys=frozenset({"name"}), + reject_unknown_keys=False, + ) + + +# --------------------------------------------------------------------------- +# When steps +# --------------------------------------------------------------------------- + + +@when("I render the secure template") +def step_when_render_securely(context: Context) -> None: + config = context.sec_config + if config is None and context.sec_allowed_keys is not None: + config = TemplateConfig(allowed_keys=context.sec_allowed_keys) + renderer = SecureTemplateRenderer(config=config) + context.sec_rendered = renderer.render(context.sec_template, context.sec_context) + + +@when("I render the secure template expecting a security error") +def step_when_render_security_error(context: Context) -> None: + config = context.sec_config or TemplateConfig() + renderer = SecureTemplateRenderer(config=config) + try: + renderer.render(context.sec_template, context.sec_context) + context.sec_error = None + except TemplateError as exc: + assert isinstance(exc, TemplateSecurityError), ( + f"Expected TemplateSecurityError, got {type(exc).__name__}: {exc}" + ) + context.sec_error = exc + + +@when("I render the secure template expecting a size error") +def step_when_render_size_error(context: Context) -> None: + config = context.sec_config or TemplateConfig() + renderer = SecureTemplateRenderer(config=config) + try: + renderer.render(context.sec_template, context.sec_context) + context.sec_error = None + except TemplateError as exc: + assert isinstance(exc, TemplateSizeError), ( + f"Expected TemplateSizeError, got {type(exc).__name__}: {exc}" + ) + context.sec_error = exc + + +@when("I render the secure template expecting a validation error") +def step_when_render_validation_error(context: Context) -> None: + config = context.sec_config + if config is None and context.sec_allowed_keys is not None: + config = TemplateConfig(allowed_keys=context.sec_allowed_keys) + elif config is None: + config = TemplateConfig() + renderer = SecureTemplateRenderer(config=config) + try: + renderer.render(context.sec_template, context.sec_context) + context.sec_error = None + except TemplateError as exc: + assert isinstance(exc, TemplateValidationError), ( + f"Expected TemplateValidationError, got {type(exc).__name__}: {exc}" + ) + context.sec_error = exc + + +@when("I validate the secure template") +def step_when_validate_template(context: Context) -> None: + allowed = context.sec_allowed_keys or frozenset() + context.sec_validation_errors = validate_template( + context.sec_template, allowed_keys=allowed + ) + + +@when("I render via the secure convenience function") +def step_when_render_convenience(context: Context) -> None: + context.sec_rendered = render_template_secure( + context.sec_template, context.sec_context + ) + + +@when("I render via the secure convenience function expecting an error") +def step_when_render_convenience_error(context: Context) -> None: + try: + render_template_secure(context.sec_template, context.sec_context) + context.sec_error = None + except TemplateError as exc: + context.sec_error = exc + + +@when("I create a renderer with those settings") +def step_when_create_renderer(context: Context) -> None: + config = context.sec_config + if config is None and context.sec_allowed_keys is not None: + config = TemplateConfig(allowed_keys=context.sec_allowed_keys) + context.sec_renderer = SecureTemplateRenderer(config=config) + + +@when("I render via the legacy TemplateRenderer") +def step_when_render_legacy(context: Context) -> None: + renderer = TemplateRenderer() + context.sec_rendered = renderer.render(context.sec_template, context.sec_context) + + +@when("I render via the legacy TemplateRenderer with config") +def step_when_render_legacy_with_config(context: Context) -> None: + config = context.sec_config + if config is None and context.sec_allowed_keys is not None: + config = TemplateConfig(allowed_keys=context.sec_allowed_keys) + renderer = TemplateRenderer(config=config) + context.sec_rendered = renderer.render(context.sec_template, context.sec_context) + + +# --------------------------------------------------------------------------- +# Then steps +# --------------------------------------------------------------------------- + + +@then('the secure template output should be "{expected}"') +def step_then_rendered_output(context: Context, expected: str) -> None: + assert context.sec_rendered == expected, ( + f"Expected {expected!r}, got {context.sec_rendered!r}" + ) + + +@then('the secure template error should mention "{text}"') +def step_then_error_mentions(context: Context, text: str) -> None: + assert context.sec_error is not None, "Expected a template error" + assert text in str(context.sec_error), ( + f"Expected '{text}' in error: {context.sec_error}" + ) + + +@then("the secure template validation should be empty") +def step_then_validation_empty(context: Context) -> None: + assert context.sec_validation_errors == [], ( + f"Expected no errors, got: {context.sec_validation_errors}" + ) + + +@then("the secure template validation should not be empty") +def step_then_validation_not_empty(context: Context) -> None: + assert len(context.sec_validation_errors) > 0, ( + "Expected validation errors but got none" + ) + + +@then('the secure template validation should mention "{text}"') +def step_then_validation_mentions(context: Context, text: str) -> None: + combined = " ".join(context.sec_validation_errors) + assert text in combined, f"Expected '{text}' in: {context.sec_validation_errors}" + + +@then('the renderer config allowed keys should contain "{key}"') +def step_then_config_has_key(context: Context, key: str) -> None: + cfg = context.sec_renderer.config # exercises the .config property (line 155) + assert key in cfg.allowed_keys, ( + f"Expected '{key}' in allowed_keys, got: {cfg.allowed_keys}" + ) diff --git a/robot/helper_security_templates.py b/robot/helper_security_templates.py new file mode 100644 index 000000000..45517efc5 --- /dev/null +++ b/robot/helper_security_templates.py @@ -0,0 +1,108 @@ +"""Robot Framework helper for secure template rendering. + +Provides a CLI-style interface for Robot to invoke the secure +template renderer. Exit code 0 = success, 1 = failure. + +Usage:: + + python robot/helper_security_templates.py render \\ +