fix(security): harden template rendering

ISSUES CLOSED: #319
This commit is contained in:
CoreRasurae
2026-02-22 11:59:33 +00:00
parent 8604bc66b6
commit a7dc917021
9 changed files with 1284 additions and 6 deletions
+87
View File
@@ -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"}),
)
+145
View File
@@ -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
```
+205
View File
@@ -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"
+243
View File
@@ -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}"
)
+108
View File
@@ -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 \\
<template> <key1=val1> <key2=val2> ...
python robot/helper_security_templates.py validate <template>
python robot/helper_security_templates.py reject <template>
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure the src directory is on the import path.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.templates.secure_renderer import ( # noqa: E402
SecureTemplateRenderer,
TemplateConfig,
TemplateError,
validate_template,
)
def _cmd_render(args: list[str]) -> int:
"""Render a template with key=value context pairs."""
if len(args) < 1:
print("Usage: render <template> [key=value ...]")
return 1
template = args[0]
context: dict[str, str] = {}
for pair in args[1:]:
if "=" in pair:
k, v = pair.split("=", 1)
context[k] = v
renderer = SecureTemplateRenderer(config=TemplateConfig())
try:
result = renderer.render(template, context)
print(f"sec-render-ok: {result}")
return 0
except TemplateError as exc:
print(f"sec-render-err: {exc}")
return 1
def _cmd_validate(args: list[str]) -> int:
"""Validate a template for security issues."""
if len(args) < 1:
print("Usage: validate <template>")
return 1
template = args[0]
errors = validate_template(template)
if errors:
for err in errors:
print(f"sec-validate-err: {err}")
return 1
print("sec-validate-ok: clean")
return 0
def _cmd_reject(args: list[str]) -> int:
"""Verify that a template is rejected as unsafe."""
if len(args) < 1:
print("Usage: reject <template>")
return 1
template = args[0]
renderer = SecureTemplateRenderer(config=TemplateConfig())
try:
renderer.render(template, {})
print("sec-reject-fail: template was accepted")
return 1
except TemplateError as exc:
print(f"sec-reject-ok: {exc}")
return 0
def main() -> int:
"""Entry point called by Robot Framework ``Run Process``."""
if len(sys.argv) < 2:
print("Usage: helper_security_templates.py <command> ...")
return 1
command = sys.argv[1]
args = sys.argv[2:]
dispatch = {
"render": _cmd_render,
"validate": _cmd_validate,
"reject": _cmd_reject,
}
handler = dispatch.get(command)
if handler is None:
print(f"Unknown command: {command}")
return 1
return handler(args)
if __name__ == "__main__":
sys.exit(main())
+82
View File
@@ -0,0 +1,82 @@
*** Settings ***
Documentation Integration tests for secure template rendering
Resource common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Test Cases ***
Safe Template Renders Correctly
[Documentation] Verify a safe template renders with correct output
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... render Hello {name} name\=World
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} sec-render-ok: Hello World
Unsafe Template Is Rejected
[Documentation] Verify attribute access template is rejected
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... reject {obj.__class__}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} sec-reject-ok
Template Validation Passes Clean Template
[Documentation] Verify validation succeeds on a clean template
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... validate {name} is {role}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} sec-validate-ok
Template Validation Fails Unsafe Template
[Documentation] Verify validation rejects unsafe template
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... validate {obj.__class__}
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} sec-validate-err
Render Rejects Index Access
[Documentation] Verify index access in template is rejected
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... reject {items[0]}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} sec-reject-ok
Reject Command Fails For Safe Template
[Documentation] Verify reject command reports failure for safe template
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... reject {name}
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} sec-reject-fail
Helper Shows Usage On No Command
[Documentation] Verify helper prints usage when no command given
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Usage
Helper Rejects Unknown Command
[Documentation] Verify helper rejects an unknown sub-command
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... bogus
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Unknown command
Render Shows Usage On No Args
[Documentation] Verify render sub-command prints usage with no arguments
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... render
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Usage
Validate Shows Usage On No Args
[Documentation] Verify validate sub-command prints usage with no arguments
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... validate
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Usage
Reject Shows Usage On No Args
[Documentation] Verify reject sub-command prints usage with no arguments
${result}= Run Process ${PYTHON} ${WORKSPACE}/robot/helper_security_templates.py
... reject
Should Be Equal As Integers ${result.rc} 1
Should Contain ${result.stdout} Usage
+28
View File
@@ -0,0 +1,28 @@
"""Template rendering with security controls.
Re-exports the secure renderer for convenience.
"""
from __future__ import annotations
from cleveragents.templates.secure_renderer import (
SecureTemplateRenderer,
TemplateConfig,
TemplateError,
TemplateSecurityError,
TemplateSizeError,
TemplateValidationError,
render_template_secure,
validate_template,
)
__all__: list[str] = [
"SecureTemplateRenderer",
"TemplateConfig",
"TemplateError",
"TemplateSecurityError",
"TemplateSizeError",
"TemplateValidationError",
"render_template_secure",
"validate_template",
]
+40 -6
View File
@@ -1,27 +1,61 @@
"""Minimal template renderer shim for reactive routing.
This is a placeholder to satisfy imports from the reactive stack. It provides a
basic render pass-through suitable for tests; replace with real templating if
needed.
Wraps :class:`~cleveragents.templates.secure_renderer.SecureTemplateRenderer`
to satisfy the existing import contract while enforcing security controls.
"""
from __future__ import annotations
import logging
from enum import Enum
from typing import Any
from cleveragents.templates.secure_renderer import (
SecureTemplateRenderer,
TemplateConfig,
TemplateError,
)
logger = logging.getLogger(__name__)
class TemplateEngine(Enum):
"""Supported template engines."""
SIMPLE = "simple"
class TemplateRenderer:
def __init__(self, engine: TemplateEngine = TemplateEngine.SIMPLE):
"""Thin wrapper over :class:`SecureTemplateRenderer`.
The ``render`` method silently returns the raw template on
any rendering failure to preserve backward compatibility.
Args:
engine: Template engine selector (only ``SIMPLE`` is supported).
config: Optional :class:`TemplateConfig` forwarded to the
underlying :class:`SecureTemplateRenderer`. When ``None``
the default (open-allowlist) config is used.
"""
def __init__(
self,
engine: TemplateEngine = TemplateEngine.SIMPLE,
config: TemplateConfig | None = None,
) -> None:
self.engine = engine
self._secure = SecureTemplateRenderer(config=config or TemplateConfig())
def render(self, template: str, context: dict[str, Any] | None = None) -> str:
"""Render *template* with *context*, returning raw on error."""
context = context or {}
try:
return template.format(**context)
except Exception:
return self._secure.render(template, context)
except TemplateError:
logger.warning(
"Template rendering failed; returning raw template "
"(length=%d). Enable DEBUG logging for details.",
len(template),
)
logger.debug("Suppressed TemplateError for template: %r", template)
return template
@@ -0,0 +1,346 @@
"""Sandboxed template renderer with strict security controls.
Replaces unsafe ``str.format()`` with a restricted token set that only
allows ``{variable_name}`` substitution from a validated allowlist.
Security properties:
- **No attribute access**: ``{obj.attr}`` is rejected.
- **No function calls**: ``{fn()}`` is rejected.
- **No format specs**: ``{val:>10}`` is rejected.
- **No index access**: ``{val[0]}`` is rejected.
- **Fixed allowlist**: Only context keys present in the allowlist can
appear in templates.
- **Length limits**: Both the input template and rendered output are
bounded to prevent denial-of-service attacks.
Usage::
renderer = SecureTemplateRenderer(
config=TemplateConfig(
allowed_keys=frozenset({"name", "description"}),
),
)
result = renderer.render("Hello {name}", {"name": "world"})
Or use the convenience function::
result = render_template_secure(
"Hello {name}",
{"name": "world"},
allowed_keys=frozenset({"name"}),
)
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any
__all__: list[str] = [
"SecureTemplateRenderer",
"TemplateConfig",
"TemplateError",
"TemplateSecurityError",
"TemplateSizeError",
"TemplateValidationError",
"render_template_secure",
"validate_template",
]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: Default maximum template length in characters.
DEFAULT_MAX_TEMPLATE_LENGTH: int = 10_000
#: Default maximum rendered output length in characters.
DEFAULT_MAX_OUTPUT_LENGTH: int = 50_000
#: Regex for a safe placeholder: ``{simple_name}`` only.
#:
#: Note: ``\w`` matches Unicode word characters (letters, digits,
#: underscore) — not just ASCII ``[a-zA-Z0-9_]``. This means keys
#: like ``{café}`` or ``{名前}`` are accepted. If ASCII-only keys
#: are required, change the pattern to ``[a-zA-Z_][a-zA-Z0-9_]*``.
_SAFE_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
#: Single combined pattern that detects **all** unsafe template
#: constructs in one pass: attribute access, index access, conversion
#: flags, format specs, function calls, and Jinja2 delimiters.
_UNSAFE_RE: re.Pattern[str] = re.compile(
r"|".join(
[
r"\{[^}]*\.[^}]*\}", # attribute access {x.y}
r"\{[^}]*\[[^}]*\]\}", # index access {x[0]}
r"\{[^}]*![^}]*\}", # conversion {x!r}
r"\{[^}]*:[^}]+\}", # format spec {x:>10}
r"\{[^}]*\([^}]*\)\}", # function call {fn()}
r"\{\{", # Jinja2 start
r"\}\}", # Jinja2 end
r"\{%", # Jinja2 block
r"%\}", # Jinja2 block end
]
)
)
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class TemplateError(Exception):
"""Base exception for all template rendering errors."""
class TemplateSecurityError(TemplateError):
"""Raised when a template contains unsafe constructs."""
class TemplateSizeError(TemplateError):
"""Raised when a template or its output exceeds size limits."""
class TemplateValidationError(TemplateError):
"""Raised when template validation fails (e.g. unknown keys)."""
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class TemplateConfig:
"""Configuration for the secure template renderer.
Attributes:
allowed_keys: The set of context key names that may appear in
templates. If empty, **all** simple identifier keys are
permitted (open-allowlist mode).
max_template_length: Maximum number of characters in the raw
template string.
max_output_length: Maximum number of characters in the rendered
output string.
reject_unknown_keys: When ``True`` (default), referencing a key
not in *allowed_keys* raises ``TemplateValidationError``.
Ignored when *allowed_keys* is empty.
log_rejected: When ``True``, log rejected keys at WARNING level.
"""
allowed_keys: frozenset[str] = field(default_factory=frozenset)
max_template_length: int = DEFAULT_MAX_TEMPLATE_LENGTH
max_output_length: int = DEFAULT_MAX_OUTPUT_LENGTH
reject_unknown_keys: bool = True
log_rejected: bool = True
# ---------------------------------------------------------------------------
# Renderer
# ---------------------------------------------------------------------------
class SecureTemplateRenderer:
"""Sandboxed template renderer with strict security controls.
Only ``{variable_name}`` substitutions are allowed. Attribute
access, index access, format specs, conversion flags, and function
calls are all rejected.
"""
def __init__(self, config: TemplateConfig | None = None) -> None:
self._config = config or TemplateConfig()
@property
def config(self) -> TemplateConfig:
"""Return the active configuration."""
return self._config
# -- Public API ---------------------------------------------------------
def render(
self,
template: str,
context: dict[str, Any] | None = None,
) -> str:
"""Render *template* with the given *context*.
Args:
template: The template string containing ``{key}``
placeholders.
context: Mapping of placeholder names to values.
Returns:
The rendered string.
Raises:
TemplateSizeError: If the template or output exceeds the
configured length limits.
TemplateSecurityError: If the template contains unsafe
constructs (attribute access, function calls, etc.).
TemplateValidationError: If the template references keys
not in the allowed set.
"""
context = context or {}
self._check_template_length(template)
self._check_security(template)
used_keys = _extract_placeholder_names(template)
self._check_allowed_keys(used_keys)
rendered = self._substitute(template, context)
self._check_output_length(rendered)
return rendered
def validate(self, template: str) -> list[str]:
"""Validate *template* without rendering.
Returns a list of error messages (empty = valid).
"""
errors: list[str] = []
if len(template) > self._config.max_template_length:
errors.append(
f"Template length {len(template)} exceeds maximum "
f"{self._config.max_template_length}"
)
for match in _UNSAFE_RE.finditer(template):
errors.append(f"Unsafe construct detected: {match.group()!r}")
# Check for unknown keys when allowlist is enforced.
if self._config.allowed_keys and self._config.reject_unknown_keys:
used_keys = _extract_placeholder_names(template)
unknown = used_keys - self._config.allowed_keys
if unknown:
errors.append(f"Unknown template keys: {', '.join(sorted(unknown))}")
return errors
# -- Internal helpers ---------------------------------------------------
def _check_template_length(self, template: str) -> None:
if len(template) > self._config.max_template_length:
raise TemplateSizeError(
f"Template length {len(template)} exceeds maximum "
f"{self._config.max_template_length}"
)
def _check_output_length(self, rendered: str) -> None:
if len(rendered) > self._config.max_output_length:
raise TemplateSizeError(
f"Rendered output length {len(rendered)} exceeds maximum "
f"{self._config.max_output_length}"
)
def _check_security(self, template: str) -> None:
match = _UNSAFE_RE.search(template)
if match:
raise TemplateSecurityError(f"Unsafe template construct: {match.group()!r}")
def _check_allowed_keys(
self,
used_keys: set[str],
) -> None:
if not self._config.allowed_keys:
return
unknown = used_keys - self._config.allowed_keys
if unknown and self._config.reject_unknown_keys:
if self._config.log_rejected:
logger.warning(
"Rejected template keys: %s",
", ".join(sorted(unknown)),
)
raise TemplateValidationError(
f"Template uses disallowed keys: {', '.join(sorted(unknown))}"
)
@staticmethod
def _substitute(template: str, context: dict[str, Any]) -> str:
"""Perform safe ``{key}`` substitution.
Unknown placeholders are left as-is (safe-substitute behaviour).
"""
def _replace(match: re.Match[str]) -> str:
key = match.group(1)
if key in context:
return str(context[key])
return match.group(0)
return _SAFE_PLACEHOLDER_RE.sub(_replace, template)
# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------
def _extract_placeholder_names(template: str) -> set[str]:
"""Extract all ``{name}`` placeholder names from *template*."""
return set(_SAFE_PLACEHOLDER_RE.findall(template))
def validate_template(
template: str,
*,
allowed_keys: frozenset[str] | None = None,
max_template_length: int = DEFAULT_MAX_TEMPLATE_LENGTH,
) -> list[str]:
"""Pre-validate a template string at creation time.
Convenience wrapper that builds a :class:`SecureTemplateRenderer`
and calls :meth:`~SecureTemplateRenderer.validate`.
Args:
template: The template string to validate.
allowed_keys: Optional set of allowed placeholder names.
max_template_length: Maximum template length.
Returns:
A list of error messages. Empty means the template is valid.
"""
config = TemplateConfig(
allowed_keys=allowed_keys or frozenset(),
max_template_length=max_template_length,
)
renderer = SecureTemplateRenderer(config=config)
return renderer.validate(template)
def render_template_secure(
template: str,
context: dict[str, Any],
*,
allowed_keys: frozenset[str] | None = None,
max_template_length: int = DEFAULT_MAX_TEMPLATE_LENGTH,
max_output_length: int = DEFAULT_MAX_OUTPUT_LENGTH,
) -> str:
"""Render a template securely with one function call.
Convenience wrapper around :class:`SecureTemplateRenderer`.
Args:
template: The template string.
context: Mapping of key names to values.
allowed_keys: Optional set of allowed placeholder names.
max_template_length: Maximum template length.
max_output_length: Maximum rendered output length.
Returns:
The rendered string.
Raises:
TemplateError: On any security, validation, or size error.
"""
config = TemplateConfig(
allowed_keys=allowed_keys or frozenset(),
max_template_length=max_template_length,
max_output_length=max_output_length,
)
renderer = SecureTemplateRenderer(config=config)
return renderer.render(template, context)