Escaping and Security

Where a value lands decides how it is escaped, and that is read from your template's own markup, so data can never escape its place. A template that cannot be made safe does not compile. On top of that, every page has a Content Security Policy with a fresh nonce, and every action passes its gates.

Where a value lands decides how it is written

The context of each value is worked out once, when the template compiles, from the HTML you wrote around it. Data cannot choose its own context, because the context is your markup.

The value stands inIt is written as
Text, <title>, <textarea>, or an attributeHTML-escaped. An unquoted attribute value also gets its spaces and = escaped.
href, src, action, formaction, srcset, poster and other URL attributesOnly http, https, mailto, tel, sms, relative URLs and data: images (png, jpeg, gif, webp, avif) pass. A link that would run script, such as javascript:, is replaced by a harmless one. After a ? the value is URL-encoded.
Code in a <script>, including JSON-LDJSON, safe inside the script. Write var cart = {{cart}}; without quotes.
A string, template text, comment or regex inside a <script>Every character but letters, digits and _ as an escape, which reads back as the value there.
A style attribute or <style>Only characters a CSS value needs. Anything else becomes unsafe. Inside url(...) it is checked as a URL.
Between attributes, <input {{attrs}}>Attribute names, or an object of attributes, each escaped for its kind. Never an event handler, and anything else is dropped.
An HTML commentHTML-escaped, and -- is broken up so it cannot end the comment.

The same data in each place, with a value that tries to break out:

Safe wherever it stands
<p>{{evil}}</p>
<div title="{{evil}}"></div>
<a href="{{link}}">A link</a>
<a href="/search?q={{evil}}">Search</a>
<div style="color: {{color}}"></div>
<input {{attrs}}>
<script nonce="{{nonce}}">
  var value = {{evil}};
  var text = "{{evil}}";
</script>

With evil as "><script>alert(1)</script>, link as javascript:alert(1) and color as red;background:url(javascript:alert(1)), the script never runs: the link becomes about:invalid#unsafe-url, the color becomes unsafe, and the rest is inert text.

Triple braces opt out. {{{value}}} and {{& value}} write the value as it is, wherever they stand. Use them only for HTML you built yourself, such as the output of web_foreach or web_template, whose values are escaped already. Never for something a visitor typed.

The same goes for the html pin of web_update, web_replace and web_insert: it is HTML. Run a visitor's text through a web_template first.

Some templates refuse to compile, with a message that says why

No single escaping is right for these shapes, so they are errors and not risks.

A partial anywhere but where text goes

A partial compiles on its own, starting in text, and must end in text too.

Does not compile

<div class="{{> part}}">
<script>const x = {{> part}};</script>
{{define "p"}}<script>{{/define}}

Compiles

<div>{{> part}}</div>
<script>const x = {{value}};</script>

A branch that ends somewhere else

The next value would otherwise be escaped for a place it may not be in. With an {{else}}, the branches must agree with each other. Without one, or in a section or a loop that may run zero or many times, the block must end where it began.

Does not compile

{{if a}}<script>{{/if}}{{v}}
{{if a}}<a href="{{else}}<b>{{/if}}
{{#items}}<div title="{{/items}}
<input {{if on}}checked{{/if}}>

Compiles

{{if a}}<a href="/x">{{else}}<a href="/y">{{/if}}
<a href="{{if a}}/x{{else}}/y{{/if}}">
<li class="row {{if on}}active{{/if}}">
<input {{if on}}checked="checked"{{/if}}>

The fourth one catches most people. A bare checked leaves the block in the middle of an attribute name, and checked="checked" ends where it began. <input {{attrs}}> with an object of attributes is the other way to write it.

A link whose scheme would come from data

A value followed by a : in your text could build javascript: out of the two.

Does not compile

<a href="{{scheme}}://{{host}}">
<a href="java{{x}}:alert(1)">

Compiles

<a href="https://{{host}}">
<a href="mailto:{{email}}">
<a href="{{url}}">, checked as a whole when it renders

A value that may be the scheme is checked together with the text before it, so {{a}}{{b}} cannot build javascript: either.

And the plain mistakes

A filter that does not exist, a block that is never closed, a closing tag for the wrong block, a second {{else}}, changed delimiters and a {{define}} used twice for one name are all errors with their line and column, too. So a typo shows up the first time the page renders, for you, and is never a blank spot for a visitor to find.

Every page has a Content Security Policy with a fresh nonce

Every page response carries this policy, with a new random nonce each time:

Content-Security-Policy
default-src 'self';
script-src 'self' 'nonce-RANDOM' https:;
style-src 'self' 'unsafe-inline' https:;
img-src 'self' data: blob: https:;
font-src 'self' data: https:;
media-src 'self' blob: https:;
connect-src 'self' https: wss:;
frame-src 'self' https:;
form-action 'self' https:;
frame-ancestors 'self';
base-uri 'self';
object-src 'none'
You wantWhat the policy says
An inline scriptIt needs nonce="{{nonce}}". Without it, the browser refuses it.
onclick and other handler attributesBlocked, as they cannot carry a nonce. data-action and an event listener in your own script replace them.
A script from a CDN or analyticsAllowed: your own files and anything over https:.
Inline styles and <style>Allowed, also over https:.
Images, fonts and mediaAllowed from your own site and https:. Images and fonts also from data:, images and media from blob:.
Fetching from a scriptYour own site, https: and wss:.
Showing a frame, or posting a form elsewhereYour own site and https:.
Being framed by another siteRefused. Only your own site may frame the page.
A <base> pointing elsewhere, or a pluginRefused.

In short, a page may load what a website loads, and is refused what an injected string would need. To set your own policy, send Content-Security-Policy through the headers pin of web_render: yours then replaces this one, nonce included, so you take over keeping the runtime script allowed.

Next to the policy, a page gets X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin unless you set them yourself.

Scopes decide who may open a page

A web_page is gated like every other page: *public for anyone, *loggedin for any signed-in user, a scope of your own, or *ip: for addresses. With no scopes at all it opens for developers only. See Remote access control.

The visitorGets
Not signed in, opening a pageA redirect to /login?redirect=..., back to the page afterwards.
Not signed in, running an action401. The page reloads, which leads to the login.
Signed in, without the scope403, with a short message.

An action passes more gates than its page

  1. Another website is refused. A POST the browser marks as coming from another site, or whose Origin is another host, gets a 403. That includes other subdomains of your domain. The session cookie on such a request is the visitor's, but the intent is not.
  2. The page's scopes must admit the visitor, as for opening it.
  3. The action's own scopes must admit the visitor, if it has any.
  4. Without scopes of its own, the scopes of every web_page in the blueprint must admit the visitor.

A blueprint with a public page and an admin page therefore cannot leak the admin actions through the public page. The reverse is the catch: an action the public page uses needs scopes: ["*public"] of its own in that blueprint, and a page without scopes closes every action without scopes to anyone but developers.

An action name means nothing outside its blueprint, and a name the blueprint does not have is a 404. A script outside a browser sends none of the headers above and passes the first gate, so never treat it as authentication: the scopes do that job.

A failure tells the visitor little

A visitor who reaches a broken page reads that it failed. Why it failed goes to the log, and on the page or in the action's answer only to the entity's blueprint developers: sessions with the setting_blueprint_view_all permission. So a template error never shows a stranger how the page is built, and you can debug a live page by opening it signed in.

On the block, a template error sets success to false and puts the message on error, and the page answers with status 500.

Next steps

Remote access control All the scopes a page or action can carry. Actions The gates at work, and what an action can answer. Template Syntax The rest of what a template can say.

Frequently asked

How does a RUAL web template stop script injection?

Where a value lands decides how it is escaped, and that is read from the template's own markup when it compiles: text and attributes are HTML-escaped, links refuse schemes that run script, script contents get JSON. Data cannot choose its place. Triple braces write a value as it is, so use those only for HTML you built yourself.

Why does my RUAL web template refuse to compile?

It holds something no single escaping is right for: a partial inside a tag, attribute or script, an if whose branches end in different places, or a link whose scheme would come from data. The error says which. Open and close tags inside each branch, and write https://{{host}} rather than {{scheme}}://{{host}}.