Docs

Security

What Proa hardens by default, the CVE classes its design answers, and what stays your job.

Open Markdown

Proa's security work splits three ways: what the template engine does automatically, what the framework hardens by default, and what remains application logic.

Knowing which bucket a concern falls into is most of the job.

ConcernWho handles it
Escaping dynamic values in HTMLProa, automatically. No opt-in
Executable URL schemes in static attributesProa, at compile time. Hard error
Executable URL schemes in dynamic attributesProa, at render time. Value is neutralized
Dynamic values inside <script> or <style>Proa warns at compile time, and masks tokenizer breakouts at render time
Dynamic on*, srcdoc, and style attributesProa warns at compile time. RSJS rejects them outright
Baseline response headersFramework, on by default. nosniff, DENY, strict-origin-when-cross-origin
HSTS, frame policy overrides, Content Security PolicyFramework, opt-in. with_security_headers() / with_strict_csp()
Cross-origin unsafe requestsFramework, on by default. Returns 403
Request body size and timeoutFramework, on by default. 1 MiB, 30 seconds
Keeping server data out of the browserFramework, enforced by the RSJS linker
CSRF tokens, sessions, authYours. Axum middleware
Input validation, sanitizing user HTMLYours. Before data reaches a template
Rate limiting, cookie attributes, CORS, safe redirectsYours. Server middleware

What Proa does automatically

Three layers run without configuration: the macro refuses to compile some markup, the escaper encodes per context at render time, and the RSJS client runtime repeats the same guards in the browser.

Compile time

html! and html_sync! walk the template AST during macro expansion and run an OWASP-derived validation pass. Errors fail the build. Warnings compile but surface at the call site with the offending expression quoted back.

CheckSeverityWhat it catches
Executable scheme in a static URL attributeErrorhref="javascript:alert(1)" and every case, whitespace, and entity variant of it
Dynamic {expr} inside <script> or <style>WarningHTML escaping is the wrong encoder for a JS or CSS context (OWASP Rules #3 and #4)
Dynamic on* handler (onclick={expr})WarningThe value lands in a JavaScript execution context, where attribute escaping does nothing
Dynamic srcdoc={expr} or style={expr}WarningSecond-order contexts: srcdoc re-parses as HTML in the iframe, style parses as CSS
Dynamic {expr} in a raw-text elementWarning<textarea>, <title>, <xmp>, <noscript>, <noframes> have non-standard parser semantics
UnsafeRaw inside <svg>, <math>, or <foreignObject>WarningForeign content re-parses in another namespace, the mutation-XSS path

The static URL error looks like this:

src/components/link.rs
html_sync! {
    <a href="javascript:alert(1)">"Click me"</a>
}
// error: dangerous URL scheme in `href` attribute. use a safe scheme
// (https:, /, #) or pass data via data-* attributes
// (OWASP XSS Prevention Cheat Sheet, Rule #7)

Two details make that check hold up against real bypass techniques rather than only the obvious spelling.

The attribute set is wide. Blocking href, src, action, and formaction is the common choice and it is not enough. Proa checks href, src, action, formaction, poster, ping, cite, data, background, longdesc, manifest, and both spellings of xlink:href. Each one is a real sink: poster fetches javascript: on older engines, ping is an exfiltration beacon, data on <object> executes, manifest was the AppCache vector. srcset is deliberately excluded, because its comma-separated url descriptor, ... grammar is not a single URL and a whole-string scheme check would be wrong there.

The scheme comparison is normalized the way a browser normalizes it. Two decoding passes happen before a URL reaches the parser, and a naive prefix match misses both:

The blocked set is javascript:, vbscript:, and the executable data: types: text/html, application/xhtml+xml, application/xml, and image/svg+xml. Raster image, audio, video, and font data URLs stay allowed, because they have legitimate inline uses. SVG is blocked even when a particular payload looks inert, because it is an executable XML container.

Render time

Every dynamic value is escaped for its context before it reaches the output buffer:

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>

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

Dynamic URL attributes get the same scheme check as static ones. A compile-time error cannot see href={user_supplied}, so the macro routes URL-bearing attributes through a runtime guard using the identical normalization. In debug builds a dangerous value trips a debug_assert!, so the mistake fails your test suite. In release builds the attribute renders with an empty value rather than the dangerous URL.

<script> and <style> content gets tokenizer masking. HTML escaping alone does not keep a value inside a raw-text element, because the tokenizer leaves raw-text state on byte sequences that escaping does not touch. Proa masks three of them with a single backslash, which is a no-op for the decoded JavaScript string value:

SequenceMasked toTokenizer transition it blocks
</script (or </style) followed by >, whitespace, or /<\/scriptCloses the raw-text element (HTML5 §13.2.5.17)
<script followed by >, whitespace, or /<\scriptEnters script-data-escaped state (HTML5 §13.2.5.27)
<!--<\!--Opens script-data-escaped state (HTML5 §13.2.5.18)

--> needs no mask of its own: every bare > is already escaped to &gt;, so the sequence cannot appear in the output, and reaching the --> transition requires an unmasked <!-- first.

This is defense in depth, not permission. The compile-time warning still fires. The masking exists so a value that slips through a suppressed warning cannot escape the element.

Island hydration payloads are masked too. RSJS and React islands serialize props into <script type="application/json">. That content is raw text and textContent does not decode entities, so HTML-escaping it would corrupt the JSON. Instead </ becomes <\/ (a valid JSON escape for /) so the HTML parser cannot match a closing tag inside the payload, and U+2028 / U+2029 become their six-character escapes, since before ES2019 those acted as line terminators inside inline scripts and allowed breakout from a JSON string literal.

In the browser

RSJS repeats the server-side guards on the client, because a client-updated attribute is the same sink as a server-rendered one.

Good to know: RSJS enforces a fourth boundary. A value that never reaches a client root never ships to the browser, so secrets held in server-only components stay on the server by construction. See Server-rendered content.

CVE precedents this design answers

These are the published vulnerabilities the escaping and validation layers were reviewed against. Each one is cited in the source next to the code that answers it, so the reasoning survives refactors.

PrecedentEcosystemThe bypassWhat Proa does
CVE-2023-24538Go html/templateScript-context escape bypass through template actions in JS contextsWarns at compile time on any dynamic value inside <script>, and masks raw-text breakout sequences at render time
CVE-2023-39318Go html/templateA nested <script inside script-data-escaped state changes tokenizer state<script followed by a tag-ending byte is masked to <\script
CVE-2023-39319Go html/template</script with trailing whitespace still closes the elementThe close-tag mask matches </script followed by >, whitespace, or /, not just </script>
CVE-2026-32635AngularThe i18n-href sanitizer allowlist missed a URL attribute, which is a complete XSS bypassDrove the wide URL-attribute set (11 attributes plus both xlink:href spellings) rather than the usual four
CVE-2026-39315UnheadhasDangerousProtocol() was defeated by HTML-entity padding in the schemeOne pass of numeric-entity decoding with no digit cap runs before the scheme comparison
DOMPurify bypass series (2019 to 2020)DOMPurifyMutation XSS: HTML-escaped content becomes executable after the browser re-parses it in the SVG or MathML namespaceWarns on UnsafeRaw inside <svg>, <math>, or <foreignObject>, and points at a namespace-aware sanitizer
OWASP XSS Prevention Rule #7Cross-ecosystemjavascript: URLs as a reflected XSS pathCompile-time hard error on static values, runtime neutralization on dynamic ones. React adopted the same block in v16.9

The data:text/html block is worth calling out separately. An allowlist that stops at javascript: and vbscript: leaves data:text/html,<script>...</script> as a working navigation payload, so the executable data: MIME types are blocked by the same code path.

Dependency advisories

Proa's own code is one half of the surface. The other half is the dependency tree, audited against the RustSec advisory database.

Terminal
cargo audit --deny warnings
cargo deny check advisories bans sources licenses

.cargo/audit.toml and deny.toml hold the accepted-advisory list. Every entry is informational (unmaintained or unsound) with no fixed release available, and every one enters through an example app, developer tooling, a build-time processor, or a dev-dependency. None reach shipped library code on a request path.

AdvisoryCrateWhy it is accepted
RUSTSEC-2025-0141bincode 1.3.3Unmaintained. Enters via syntect's dump loader in proa_docs_build at docs build time, where it deserializes only its crate-embedded syntax pack. No untrusted bytes reach it
RUSTSEC-2025-0119number_prefix 0.4.0Unmaintained. Enters only via indicatif progress bars in the proa-cli developer tool
RUSTSEC-2026-0097rand 0.8.5 / 0.9.2Unsound only when a custom logger re-enters rand::rng(), with no fixed release. Paths are SQLx's optional MySQL and Postgres drivers (rand 0.8.5), a proptest dev-dependency, and reqwest to quinn transitively (rand 0.9.2), where the condition does not apply on a server render path

The policy is that a false positive or accepted risk gets an entry in both files with a justification, and severity is never loosened globally. Revisit an entry when its parent dependency updates.

What the framework hardens

FrameworkBuilder::build() applies the same outer hardening to pages and endpoints:

DefaultEffect
Baseline response headersX-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, set only when not already present
Cross-origin protectionBrowser-issued cross-origin unsafe requests return 403 unless trusted
Body limitRequests capped at 1 MiB unless overridden
Request timeoutHandlers must respond within 30 seconds by default

Frame-policy overrides, HSTS, and Content Security Policy are opt-in, because each deployment needs its own. See Headers and CSP.

Cross-origin protection follows the pattern Go 1.25 ships as net/http's CrossOriginProtection. Rather than synchronizer tokens, it reads the Sec-Fetch-Site metadata header that every evergreen browser sends, with an Origin-versus-Host fallback for older clients. Safe methods always pass. A request carrying neither header passes, because it did not come from a browser and CSRF does not apply to it.

That is not a CSRF-token service. It rejects the shape of the request, not a forged token. Cross-origin and CSRF explains the difference and what you still need to add.

What stays yours

Proa gives you the attribute support these need, nonce, integrity, http-equiv, sandbox, allow, but the policy is yours:

Two things no policy can rescue: inline event-handler attributes (onclick="…") never execute under a nonce policy, and a raw <script> tag you write by hand needs an explicit nonce read from cx.csp_nonce().

For untrusted data that genuinely has to reach client JavaScript, the supported pattern is <script type="application/json"> plus JSON.parse, not interpolation into a JS string literal. The macro escapers mask tokenizer breakouts, but they do not JSON-encode JavaScript string literals.

Reporting a vulnerability

Report privately through the Security tab of the repository on GitHub, using Report a vulnerability. That opens a private advisory visible only to you and the maintainers. Do not open a public issue with exploit details. See SECURITY.md for the supported surface and disclosure process.

Next steps

Search

Type at least 2 characters