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 in | It is written as |
|---|---|
Text, <title>, <textarea>, or an attribute | HTML-escaped. An unquoted attribute value also gets its spaces and = escaped. |
href, src, action, formaction, srcset, poster and other URL attributes | Only 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-LD | JSON, 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 comment | HTML-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:
<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:
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 want | What the policy says |
|---|---|
| An inline script | It needs nonce="{{nonce}}". Without it, the browser refuses it. |
onclick and other handler attributes | Blocked, as they cannot carry a nonce. data-action and an event listener in your own script replace them. |
| A script from a CDN or analytics | Allowed: your own files and anything over https:. |
Inline styles and <style> | Allowed, also over https:. |
| Images, fonts and media | Allowed from your own site and https:. Images and fonts also from data:, images and media from blob:. |
| Fetching from a script | Your own site, https: and wss:. |
| Showing a frame, or posting a form elsewhere | Your own site and https:. |
| Being framed by another site | Refused. Only your own site may frame the page. |
A <base> pointing elsewhere, or a plugin | Refused. |
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 visitor | Gets |
|---|---|
| Not signed in, opening a page | A redirect to /login?redirect=..., back to the page afterwards. |
| Not signed in, running an action | 401. The page reloads, which leads to the login. |
| Signed in, without the scope | 403, with a short message. |
An action passes more gates than its page
- Another website is refused. A
POSTthe browser marks as coming from another site, or whoseOriginis another host, gets a403. That includes other subdomains of your domain. The session cookie on such a request is the visitor's, but the intent is not. - The page's scopes must admit the visitor, as for opening it.
- The action's own scopes must admit the visitor, if it has any.
- Without scopes of its own, the scopes of every
web_pagein 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}}.