Skip to content

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:

  1. 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.
  2. 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:

  1. 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 \t only if free-form prose).
  2. 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 ASCII a), over-long input, every value in RESERVED_NAMES if the field accepts an identifier.
  3. 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.
  4. 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.
  5. Run just ci — the workspace-level clippy::pedantic lints catch most "did you forget to validate" mistakes (e.g. String flowing directly into format!() without a validate_* 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 no escape_rust filter — 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 through escape_yaml or pipe it through a format!("{:?}", …) proxy elsewhere — never inline it raw.
  • Filesystem paths. Path interpolation 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-credentials and stored separately. Templates may emit CredentialRef placeholders (env-var names) but never literal secret material.

See also