Template security¶
The rtb scaffolder turns user-supplied values (CLI flags, the
interactive wizard, a previously-written manifest) into source code,
config files, and documentation. Every one of those values lands in a
template-rendered output at some point. If a value reaches a
rendering site unconstrained, an attacker — or just a sloppy operator —
can break out of the surrounding context (YAML scalar → arbitrary
map key, Markdown prose → fenced code block, shell single-quoted arg
→ shell command).
rtb-cli-bin defends against this class with two layers, both
mandatory:
- Validation (primary defence) in
[
crates/rtb-cli-bin/src/validate.rs]. Every user-influenced field is NFC-normalised and matched against a tight character class before it reaches the renderer. Most injection vectors collapse here because the dangerous characters are not in the class. - Context-aware escaping (defence-in-depth) in
[
crates/rtb-cli-bin/src/template/escapers.rs]. Templates pipe fields through the appropriate filter at every non-code render site ({{ name | escape_yaml }},{{ blurb | escape_markdown }}, …). If a future change widens a validator or a new input path reaches a render site, the filter keeps the output syntactically valid.
Both layers mirror their counterparts in
go-tool-base —
specifically internal/generator/validate.go and
internal/generator/template_escape.go. Field-for-field parity is
intentional so the gtb and rtb scaffolders impose the same constraints
on shared concepts (tool names, env prefixes, command paths).
Validators (slice 8b)¶
Each function lives in [crate::validate]. All take &str and return
Result<(), Error>. NFC normalisation is the first step in every one
of them.
| Function | Rule | Used for |
|---|---|---|
validate_name |
^[a-z][a-z0-9-]{0,63}$, not in RESERVED_NAMES |
Tool name (rtb generate project -n widget); top-level command names |
validate_description |
NFC, ≤ 500 bytes, no control chars except \t, no {{ / }} |
Tool description; flag descriptions |
validate_env_prefix |
empty OR ^[A-Z][A-Z0-9_]{0,31}$ |
The MYTOOL_* prefix for env-var-driven config |
validate_command_path |
/-separated, each segment ^[a-z][a-z0-9-]{0,63}$, depth ≤ 4, first segment not reserved |
generate command kube/ctx/use |
validate_flag_name |
^[a-z][a-z0-9-]{0,63}$, no leading --, ≤ 64 bytes |
generate flag --name dry-run |
Reserved names¶
[crate::validate::RESERVED_NAMES] is the canonical list of identifiers
the scaffolder refuses to let users re-use as top-level commands or
tool names. It includes:
- Every framework default surfaced by
rtb-cli(version,doctor,update,docs,mcp,config,credentials,telemetry,init). - The scaffolder's own verbs (
generate,remove,regenerate). - clap built-ins (
help).
When a new framework default lands, add it here in the same MR.
Escapers (slice 8b)¶
Each filter lives in [crate::template::escapers] and is registered
on the [crate::template::environment] returned by the module entry
point. Templates call them with MiniJinja's pipe syntax.
| Filter | Contract | When to use |
|---|---|---|
escape_yaml |
Wraps the value in "..."; escapes backslash, quote, control bytes; strips NUL. |
Any YAML scalar value (name: {{ name \| escape_yaml }}). |
escape_toml |
Body only — caller wraps in "...". Escapes backslash, quote, \b\t\n\f\r named, other controls as \u00XX. Strips NUL. |
TOML basic strings (name = "{{ name \| escape_toml }}"). |
escape_markdown |
Backslash-escapes CommonMark construct initiators (`, \, *, [, ], <, >, \|, {, }, !, #). Strips NUL; normalises line endings. Inline prose only — headings + fenced code blocks need separate constraints. |
Markdown prose interpolation (> {{ blurb \| escape_markdown }}). |
escape_shell_arg |
Wraps in '...'; escapes interior ' as '\''. |
Any sh-executed recipe body (echo {{ msg \| escape_shell_arg }}). |
Identity-on-safe-class guarantee¶
For any input matching [a-zA-Z0-9 _.,/-], every escaper is the
identity function (modulo the YAML and shell quote wraps). Clean-input
projects see no semantic diff after piping values through these
filters. This is what makes "always pipe at the render site" cheap
enough to be the default.
Contract for adding a new user-facing field¶
When you introduce a field that flows from user input into a template:
- Add a
validate_<field>function to [crates/rtb-cli-bin/src/validate.rs]. NFC-normalise first; pick the tightest character class that actually meets the field's semantics; cap length; reject control characters (allow\tonly if free-form prose). - Add unit tests covering: the happy path, NFC normalisation, empty
input, leading-digit / leading-hyphen edge cases, Unicode
homoglyphs (e.g. Cyrillic
аU+0430 vs ASCIIa), over-long input, every value inRESERVED_NAMESif the field accepts an identifier. - At every template render site that uses the field, pipe through the appropriate escaper. Pick by output context, not by the field's semantics — the same field rendered into YAML and Markdown needs both filters, on different lines.
- Add a row to the Validators table above and, if the field needs a context-specific escape (rare beyond the four already shipped), a row to Escapers.
- Run
just ci— the workspace-levelclippy::pedanticlints catch most "did you forget to validate" mistakes (e.g.Stringflowing directly intoformat!()without avalidate_*call upstream).
What this layer does not cover¶
- Code-rendered output (
*.rs.j2). The scaffolder's identifier fields are constrained tight enough that they could be interpolated verbatim into Rust source. There is noescape_rustfilter — the validator is the only check, and the surrounding template treats the value as a token. If you find yourself wanting to put user-supplied free-form text into a Rust string literal, run it throughescape_yamlor pipe it through aformat!("{:?}", …)proxy elsewhere — never inline it raw. - Filesystem paths.
Pathinterpolation is handled by the preset walker, not by these filters. The walker rejects..segments and absolute paths at template-load time; downstream rendering only sees pre-validated relative paths. - Secrets. Secret values do not reach templates — they are routed
through
rtb-credentialsand stored separately. Templates may emitCredentialRefplaceholders (env-var names) but never literal secret material.
See also¶
- v0.6 scope spec — the slicing plan
for
rtb-cli-bin, including the per-slice validators that arrive with each new field. - Engineering Standards §1 — the non-negotiable security baseline that this doc sits beneath.
- gtb's
internal/generator/validate.goandinternal/generator/template_escape.go— the field-for-field originals.