FlawOpen

flawopen.com/xss/Rust

● CWE-79 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 Html wrapper only sets Content-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 an html extension. The |safe filter tells it the value is already safe markup and must not be escaped. Use |safe only 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 in PreEscaped(...) 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 and javascript: 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 href attribute, but javascript:alert(1) is still a valid link that runs code when clicked. Parse with Url::parse and allow only http and https before putting a user-supplied URL in href or src.

Framework mappings

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
// 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())
}
HARDENED SECURE PATCH
// 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

Sources