Output and escaping
#Everything printed is escaped
{{ }} escapes its value for HTML: &, <, >, " and ' become character references. This applies to every value, whatever its source.
<p title="{{ settings.text }}">{{ settings.text }}</p>{"settings": {"text": "<script>alert(\"hi\")</script> & 'more'"}}<p title="<script>alert("hi")</script> & 'more'"><script>alert("hi")</script> & 'more'</p>There is no way for a template to turn escaping off: no raw filter, no {% raw %} block, no "safe" marker. Literal text in the template itself is printed as written, because the author wrote it.
#Escaping never happens twice
The escape filter returns text that is already escaped, so printing it does not escape it again. escape_once escapes text that may already contain character references, without doubling them.
{{ settings.text | escape }} | {{ settings.encoded | escape_once }}{"settings": {"text": "a < b", "encoded": "a < b & c"}}a < b | a < b & cnewline_to_br escapes the text and turns each line break into <br>:
{{ settings.address | newline_to_br }}{"settings": {"address": "1 Tea Street\n<Leeds>"}}1 Tea Street<br><Leeds>#JSON
json turns a value into JSON. Its result is safe in two places:
- **Inside
<script>**, it is printed as JSON, with<,>and&encoded so the data cannot close the script element. - Inside an HTML tag (an attribute value), it is additionally escaped for HTML, because JSON's quotes would otherwise end the attribute.
<script>window.product = {{ settings.product | json }};</script>
<div data-product="{{ settings.product | json }}"></div>{"settings": {"product": {"name": "Cup <large>", "price": 12}}}<script>window.product = {"name":"Cup \u003clarge\u003e","price":12};</script>
<div data-product="{"name":"Cup \u003clarge\u003e","price":12}"></div>Vascula decides which case applies by reading the literal markup of the template in source order. If a tag is opened in one branch of an if and closed in another, it reads them in the order written.
#Trusted HTML comes only from the application
Rich text that has already been sanitized, such as a product description, needs to be printed as HTML. Only the application can do that, by handing the template a trusted value (vascula.SafeHTML in Go). A template can print it but cannot create one.
Trusted HTML is markup for the page body. Inside a tag it is only text: in an attribute value its tags are removed and the rest is escaped like any string, and where an attribute goes it prints only as a plain attribute name. Markup that adds attributes is a separate trusted value (vascula.HTMLAttr in Go), which the application should only produce itself, such as a filter returning loading="lazy"; it prints as written only where an attribute goes.
#Links and other URLs
Escaping keeps a value inside its attribute, but a value in a URL attribute can still choose the URL's scheme, and javascript: runs when followed. When anything printed stands in the value of a URL attribute, Vascula checks the whole value once the attribute is complete: the literal text and the printed parts together. It must be a relative URL (a path, ?query or #fragment) or use http, https, mailto or tel. Otherwise the whole value prints as #.
<a href="{{ settings.link }}">Ours</a> <a href="{{ settings.bad }}">Theirs</a>{"settings": {"link": "/pages/about?from=footer", "bad": "javascript:alert(1)"}}<a href="/pages/about?from=footer">Ours</a> <a href="#">Theirs</a>The check reads the value as a browser would: character references are decoded, leading and trailing spaces and control characters are ignored, tabs and line breaks are removed, and case does not matter. So href=" {{ x }}" with javascript:..., or href="java{{ x }}" with script:..., prints href="#" too. A value after a fixed start is part of a path, query or fragment and prints as written:
<a href="/search?q={{ settings.q }}">Search</a>{"settings": {"q": "tea:green"}}<a href="/search?q=tea:green">Search</a>A URL attribute written entirely in the template is the author's and is not checked.
The check applies to these attributes, in HTML and SVG: href, src, srcset, action, formaction, poster, data, cite, background, ping, manifest, xmlns, xlink:href and the obsolete archive, classid, codebase, icon, longdesc, profile and usemap. As in Go's html/template, a data- prefix is ignored (data-src is a source), and any other attribute whose name contains src, uri or url is treated as a URL.
- Images: the
srcof<img>and<source>andurl()instylealso accept an image data URL (data:image/...). No other data URL is accepted. - **
srcset:** each image candidate is checked, read as browsers readsrcset: a URL runs to the next space, so a comma inside it (/image?crop=1,2 1x) is part of it. An unsafe candidate URL prints as#and keeps its width or density descriptor. - **
style:** eachurl(...)argument in the value is checked. A value printed as the start of aurl()argument also has its spaces, quotes, parentheses and backslashes percent-encoded, so it cannot end theurl().
#Where output can stand in a tag
- Quoted attribute values (
class="{{ x }}") are the place for output. - Unquoted attribute values (
class={{ x }}) work too: spaces, quotes,,=,/,<and>` in the value print as character references, so the value cannot end early or add attributes. - Where an attribute goes (
<img src="..." {{ image_attributes }}>), attribute markup the application vouched for (HTMLAttr) prints as written. Trusted HTML does not count. Any other value must be one attribute name that is not an event handler,styleor a URL attribute (hidden), and otherwise prints nothing. - Names cannot be printed:
<h{{ n }}>,<a {{ name }}="...">,<a data-{{ key }}="...">, and two values printed with no space between them in a tag fail to compile. Write names in the template and print values.
As in Go's html/template, compilation follows control flow. Every branch of an if, unless or case must end in the same place in the markup (an if without else must end where it started), a loop body must end where it started, and break and continue must stand where their loop starts. So a branch cannot open a tag or attribute value that another branch does not:
<a href="{% if settings.local %}/pages/{% else %}https://example.com/{% endif %}{{ settings.page }}">About</a>{"settings": {"local": true, "page": "about"}}<a href="/pages/about">About</a>A name is never split by control flow. A branch may end inside a name when the text after the branch ends it with a space, /, = or >:
<input type="checkbox" {% if settings.on %}checked{% endif %}>But text or output that would continue the name on one path only fails to compile, as does a loop body or break/continue ending inside a name, or a loop starting inside one:
<a {% if settings.c %}h{% endif %}ref="{{ settings.link }}">would continue a name that control flow may end insideA template, and a capture body, must not end inside a tag.
#What escaping does not cover
HTML escaping makes text safe as text and as a quoted attribute value. It does not make a value safe as:
- a URL in other places. The URL check above covers attribute values. A URL printed into a
<script>, an event handler or a<meta>refresh is not checked. Validate URLs in the application as well. - CSS or JavaScript code. Do not print values into
style="...",onclick="..."or the body of a<script>, other than throughjson.
Vascula is not an HTML sanitizer. Anything the application passes in as trusted HTML is its responsibility.
Next: Filters.