hecr is an HTML templating library; its security centers on output escaping. This document states what is escaped, what is not, and where the sharp edges are.
Every interpolated value is HTML-escaped unless it is explicitly marked safe:
- Body interpolation
{expr}— escaped (<>&"'). Numbers and booleans need no escaping;nilrenders nothing. - Dynamic attribute values
name={expr}— escaped, inside quotes. The attribute name is a compile-time literal in the template, so it is never attacker-controlled. - Spread attributes
{expr}(aNamedTuple/Hash/pairs) — both keys and values are escaped. A key carrying"<>&'cannot break out of the tag. - Class lists
class={[...]}— each non-safe member is escaped. - Static attributes
name="literal"— emitted verbatim. The literal is authored in the template, not runtime data, so it is trusted by definition.
Hecr.raw(string) (and any value already of type Hecr::Safe) is emitted
verbatim, unescaped, in both body and attribute position. This is the
single intentional bypass — the one way to opt out of escaping. The rule:
Never pass attacker-controlled data to
Hecr.raw. Wrapping untrusted input inrawre-introduces XSS, including breaking out of an attribute value if the raw content is placed inattr={Hecr.raw(...)}.
Components return Hecr::Safe precisely so that composing components does not
double-escape; that is safe because component output was itself produced by
escaping its inputs.
Because templates compile at build time, whole classes of problem are caught before deploy rather than at runtime:
- malformed HTML (unclosed/mismatched tags) is a build error, not silently-shipped broken markup;
- a mistyped or missing component attribute is a build error;
- embedded
{expr}Crystal is parsed at build time — a syntax error fails the build.
There is no runtime template evaluation and no eval-style path: a template
is fixed Crystal code after compilation. User input flows only through the
escaped data path above, never as template structure.
The template front end is hardened against pathological input (it also runs in the editor LSP on every keystroke):
- Nesting depth is capped (
Parser::MAX_NESTING_DEPTH, 1000) so deeply nested input raises aParseErrorinstead of overflowing the recursive formatter/codegen/AST-walkers. - The tokenizer and parser are iterative; brace matching reuses Crystal's own lexer. The interpolation and tag-scanning regexes use prefix-disjoint alternatives, so they stay linear on pathological input (no ReDoS — the LSP runs them on every keystroke).
- The parser and the LSP analyzer never raise anything but a located
ParseError— fuzzed over tens of thousands of random byte strings with zero unexpected exceptions. They run on every keystroke against half-typed and pasted buffers, so this invariant is load-bearing and spec-enforced. - The formatter is fuzzed: across hundreds of generated templates it never turns a valid template into an invalid one and is always idempotent.
- The LSP server treats every inbound frame defensively: a malformed
JSON body, a request missing required fields, or an unknown method is
logged and skipped — one bad message never terminates the session. Memory
is bounded at every input boundary: a hostile
Content-Length(MAX_CONTENT_LENGTH, 32 MiB), header lines and header count (MAX_HEADER_LINE/MAX_HEADERS), and incremental-edit ranges (clamped, so an inverted or out-of-bounds range can neither crash nor duplicate document text). The workspace indexer skips files overMAX_INDEX_FILE_SIZE(1 MiB) so a large generated/vendored file cannot slow every rebuild, andDir.globdoes not follow symlink cycles. - The compile-time processor turns malformed invocations and missing or unreadable template files into clear errors, never an unhandled backtrace.
- Generated
#<loc>pragmas sanitize the filename (",>,\, newlines), so a project path containing those characters can neither break the pragma nor inject into the compiler's location stream.
Templates are normally authored by trusted developers (they are source code, not user input). These bounds matter most for the LSP, which parses whatever is in the editor buffer, including half-typed and pasted content.
This is a pre-release project. Report security issues via a GitHub issue on
tomazzlender/hecr until a private disclosure channel is established.