flawopen.com/xss/Rust
Cross-Site Scripting (XSS) in Rust (axum, Askama, ammonia)
Returning Html(format!(...)), marking a template value |safe, or placing an unchecked URL in href lets a visitor's text run as JavaScript in other users' browsers, even in memory-safe Rust (CWE-79). How Askama and maud escape for you, where they stop, and how ammonia and url::Url close the remaining gaps.
A museum puts valuable objects behind shatterproof glass, but that does not make every sign beside them trustworthy. If a visitor's note is printed as an official museum notice, readers may follow it because it appears to come from the museum. Rust's memory safety is like the case: it protects the server process, not the browser parsing the page. Askama normally escapes text; |safe removes that boundary, so sanitize allowed rich text before using it.
Core Concepts & Subsystem Terms
Html(format!(...))- axum's
Htmlwrapper only setsContent-Type: text/html; it escapes nothing.format!places the value into the markup verbatim, so the response contains whatever the user sent. Askama autoescape and |safe- Askama HTML-escapes every
{{ expr }}in templates with anhtmlextension. The|safefilter tells it the value is already safe markup and must not be escaped. Use|safeonly on values produced by a sanitizer or by your own code. maud and PreEscaped- maud's
html!macro escapes spliced values such as(name)automatically. Wrapping a value inPreEscaped(...)opts out, just like Askama's|safe, and needs the same care. ammonia::clean- ammonia parses HTML with html5ever and keeps an allowlist of tags, attributes and URL schemes.
ammonia::clean(input)removes scripts, event handlers andjavascript:URLs while keeping ordinary formatting, so the result can be passed through|safe. URL scheme checks with url::Url- Escaping keeps a value inside the
hrefattribute, butjavascript:alert(1)is still a valid link that runs code when clicked. Parse withUrl::parseand allow onlyhttpandhttpsbefore putting a user-supplied URL inhreforsrc.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Input reaches the handler
axum's Query extractor deserializes q, bio and website from the query string into String fields. A victim can be sent a crafted link, or the values are stored and rendered for every visitor.
The value bypasses escaping
format! writes q straight into the markup, |safe disables escaping for bio, and escaping website does not stop a javascript: URL. The benign test value <img src=x onerror=alert(1)> passes through intact.
The browser parses it as markup
The Html response is served as text/html, so the browser builds a real <img> element and fires onerror; the javascript: link runs when clicked.
The script runs as the site
Rust's memory safety has no say in what the browser does next: the code runs with the site's origin, can call its APIs with the victim's cookies and can change what the user sees.
Source Code: Flaw vs. Secure Implementation
// profile.rs: each handler below lets request text reach the page as HTML
use askama::Template;
use axum::{extract::Query, response::Html};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Params {
q: Option<String>,
bio: Option<String>,
website: Option<String>,
}
// Html only sets the content type; format! inserts q verbatim
pub async fn search(Query(p): Query<Params>) -> Html<String> {
Html(format!("<h1>Results for {}</h1>", p.q.unwrap_or_default()))
}
// |safe disables escaping for bio, and website is not scheme-checked
#[derive(Template)]
#[template(source = "<div class=\"bio\">{{ bio|safe }}</div><a href=\"{{ website }}\">Website</a>", ext = "html")]
struct ProfilePage {
bio: String,
website: String,
}
// request values go straight into the template
pub async fn profile(Query(p): Query<Params>) -> Html<String> {
let page = ProfilePage { bio: p.bio.unwrap_or_default(), website: p.website.unwrap_or_default() };
Html(page.render().unwrap_or_default())
}
// profile.rs: templates escape text, HTML is sanitized, URLs are checked
use askama::Template;
use axum::{extract::Query, response::Html};
use serde::Deserialize;
use url::Url;
#[derive(Deserialize)]
pub struct Params {
q: Option<String>,
bio: Option<String>,
website: Option<String>,
}
// an html template escapes q automatically
#[derive(Template)]
#[template(source = "<h1>Results for {{ q }}</h1>", ext = "html")]
struct SearchPage {
q: String,
}
pub async fn search(Query(p): Query<Params>) -> Html<String> {
Html(SearchPage { q: p.q.unwrap_or_default() }.render().unwrap_or_default())
}
// bio is sanitized before |safe, website is scheme-checked below
#[derive(Template)]
#[template(source = "<div class=\"bio\">{{ bio|safe }}</div><a href=\"{{ website }}\">Website</a>", ext = "html")]
struct ProfilePage {
bio: String,
website: String,
}
fn safe_website(raw: &str) -> String {
match Url::parse(raw) {
Ok(url) if matches!(url.scheme(), "http" | "https") => url.to_string(),
_ => "#".to_string(),
}
}
// ammonia cleans the bio and only http/https websites survive
pub async fn profile(Query(p): Query<Params>) -> Html<String> {
let page = ProfilePage {
bio: ammonia::clean(&p.bio.unwrap_or_default()),
website: safe_website(&p.website.unwrap_or_default()),
};
Html(page.render().unwrap_or_default())
}
Engineering & System Hardening Checklist
- ✓Add a Semgrep rule or review check that rejects
Html(format!(...)),Html(String)built from request data,|safeon template values that did not come fromammonia::cleanandPreEscaped(...)on non-constant values. - ✓Render every HTML response through Askama, maud or another auto-escaping template engine; keep template files on the
htmlextension so escaping stays on. - ✓Pass user-supplied HTML through
ammonia::clean(or a configuredammonia::Builder) before it reaches|safeorPreEscaped. - ✓Validate user-supplied URLs with
Url::parseand anhttp/httpsallowlist before rendering them intohreforsrc. - ✓Add a Content-Security-Policy with a nonce-based
script-srcusing a tower-http header layer, and set session cookiesHttpOnly,SecureandSameSite=Lax.