Skip to content

Security: tomazzlender/hecr

Security

SECURITY.md

Security model

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.

Escaping is the default

Every interpolated value is HTML-escaped unless it is explicitly marked safe:

  • Body interpolation {expr} — escaped (<>&"'). Numbers and booleans need no escaping; nil renders 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} (a NamedTuple/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.

The one escape hatch: Hecr.raw / Hecr::Safe

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 in raw re-introduces XSS, including breaking out of an attribute value if the raw content is placed in attr={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.

Compile-time safety

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.

Denial-of-service bounds

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 a ParseError instead 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 over MAX_INDEX_FILE_SIZE (1 MiB) so a large generated/vendored file cannot slow every rebuild, and Dir.glob does 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.

Reporting

This is a pre-release project. Report security issues via a GitHub issue on tomazzlender/hecr until a private disclosure channel is established.

There aren't any published security advisories