Docs

Escaping and raw HTML

How Proa encodes dynamic values, and the two ways to opt out.

Open Markdown

Proa's primary security job is output encoding: making sure a dynamic value cannot break out of its HTML context and execute. Every value in html! is escaped before it reaches the buffer, with no opt-in.

src/components/comment.rs
let user_input = "<script>alert('xss')</script>";

html_sync! {
    <p>{user_input}</p>
}
// <p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>

Encoding contexts

Proa escapes per the OWASP XSS Prevention Cheat Sheet, and the context decides the rule:

ContextCharacters escapedOWASP rule
Text content&, <, >, U+00A0, U+0000#1
Attribute values&, <, >, ", ', U+00A0, U+0000#2
<script> content&, <, >, plus </script, <script, and <!-- masked with \Tag breakout
<style> content&, <, >, plus </style, <style, and <!-- masked with \Tag breakout

All attribute values are always double-quoted. There is no code path that emits an unquoted attribute.

Dangerous URL schemes

javascript: and vbscript: execute arbitrary script when navigated to, and so do the executable data: types (text/html, application/xhtml+xml, application/xml, image/svg+xml). Proa blocks all of them in every URL-bearing attribute: href, src, action, formaction, poster, ping, cite, data, background, longdesc, manifest, and both spellings of xlink:href. The check runs during macro expansion and produces a compile error:

src/components/link.rs
html_sync! {
    <a href="javascript:alert(1)">"Click me"</a>
}
// error: dangerous URL scheme in `href` attribute.

The comparison normalizes the value the way a browser does before parsing it, so the usual evasions fail too. ASCII whitespace and NUL are skipped throughout the scheme (not just at the leading edge), which catches java&Tab;script: and \njavascript:, and one pass of numeric character reference decoding runs first, which catches &#106;avascript:alert(1) and its zero-padded variants.

Dynamic URLs get the same check at render time. A compile-time error cannot see href={user_supplied}, so URL-bearing attributes route through a runtime guard using the identical normalization. In debug builds a dangerous value trips a debug_assert!; in release builds the attribute renders empty rather than dangerous.

That guard is a backstop, not a substitute for validation. It only recognizes executable schemes, so an open redirect or an attacker-chosen host still reaches the page. Validate user-controlled URLs in application code.

Related: when you write <a target="_blank">, add rel="noopener noreferrer" unless you deliberately want an opener relationship. Without it the opened page can redirect your original tab through window.opener, reverse tabnabbing. Modern browsers default to noopener, but being explicit preserves behaviour in older clients.

Dynamic values inside script and style

Proa emits a compile-time warning when a dynamic expression appears inside <script> or <style>:

src/layouts/root.rs
html_sync! {
    <script>
        {some_dynamic_value}  // warning: OWASP XSS: dynamic expr in script/style
    </script>
}

HTML escaping is not sufficient here. JavaScript and CSS are separate encoding contexts needing JS-specific and CSS-specific encoding, which an HTML engine does not provide.

Pass the data through a data-* attribute instead, and read it from the DOM:

src/layouts/root.rs
use proa_core::StaticRaw;

html_sync! {
    <div id="app" data-config={serde_json::to_string(&config).unwrap()}></div>
    <script>
        {StaticRaw(r#"
            const config = JSON.parse(
                document.getElementById('app').dataset.config
            );
        "#)}
    </script>
}

The attribute value goes through attribute escaping, and the script reads it via the DOM. No injection path.

The two escape hatches

StaticRaw takes a compile-time constant. No user data can reach it at runtime, so it is inherently safe:

src/components/icon.rs
use proa_core::StaticRaw;

StaticRaw("<svg viewBox='0 0 24 24'>...</svg>")

UnsafeRaw takes a dynamic string and escapes nothing. Use it only with content you have already sanitized:

src/pages/article.rs
use proa_core::UnsafeRaw;

let sanitized = ammonia::clean(&user_html);
UnsafeRaw(&sanitized)

Never wrap request input, CMS strings, or user-authored Markdown in UnsafeRaw directly. Sanitize with an allowlist first, ammonia on the server, DOMPurify in the browser.

Good to know: The typed AttrKey enum deliberately excludes event-handler attributes such as onclick. Inline handlers cannot execute under a Content Security Policy script-src directive, so the typed API does not encourage them. The macro still accepts them if you write them by hand.

Next steps

Search

Type at least 2 characters