@@ -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"}),
|
||||
)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user