Skip to main content

acme_proxy_admin/webadmin/pages/
assets.rs

1//! `GET /ui/static/{file}` — the two vendored assets, out of the binary.
2//!
3//! Embedded rather than served from disk: a deployment stays one executable,
4//! and `tower-http`'s `fs` feature — with the path-traversal surface that comes
5//! with it — stays off. The match below is the entire allowlist, so there is no
6//! traversal to defend against in the first place.
7//!
8//! **Outside the session requirement.** The sign-in page needs the stylesheet
9//! before anyone has a session, and neither file says anything about this
10//! deployment: `htmx.min.js` is a public release (see `static/README.md` for
11//! its provenance) and `admin.css` is a stylesheet.
12
13use axum::extract::Path;
14use axum::http::{StatusCode, header};
15use axum::response::{IntoResponse, Response};
16
17const HTMX_JS: &str = include_str!("../static/htmx.min.js");
18const ADMIN_CSS: &str = include_str!("../static/admin.css");
19
20/// `GET /ui/static/{file}`.
21pub async fn get_asset(Path(file): Path<String>) -> Response {
22    let (content_type, body) = match file.as_str() {
23        "htmx.min.js" => ("text/javascript; charset=utf-8", HTMX_JS),
24        "admin.css" => ("text/css; charset=utf-8", ADMIN_CSS),
25        _ => return StatusCode::NOT_FOUND.into_response(),
26    };
27
28    (
29        [(header::CONTENT_TYPE, content_type)],
30        // Not `Html`/`Json`: these are served verbatim, and the response
31        // hardening layers (`nosniff` in particular) are applied at the router
32        // root and reach here unchanged.
33        body,
34    )
35        .into_response()
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    #[tokio::test]
43    async fn each_known_asset_is_served_with_its_own_type() {
44        for (file, content_type, marker) in [
45            ("htmx.min.js", "text/javascript; charset=utf-8", "htmx"),
46            ("admin.css", "text/css; charset=utf-8", ".htmx-indicator"),
47        ] {
48            let response = get_asset(Path(file.to_string())).await;
49            assert_eq!(response.status(), StatusCode::OK, "{file}");
50            assert_eq!(response.headers()[header::CONTENT_TYPE], content_type);
51
52            let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
53                .await
54                .unwrap();
55            let body = String::from_utf8(bytes.to_vec()).unwrap();
56            assert!(body.contains(marker), "{file} looks like the wrong file");
57        }
58    }
59
60    /// The allowlist is the whole security model here, so a name outside it —
61    /// including anything that looks like traversal — is a plain `404`.
62    #[tokio::test]
63    async fn an_unknown_name_is_not_found() {
64        for file in ["../Cargo.toml", "htmx.js", "", "admin.css/"] {
65            let response = get_asset(Path(file.to_string())).await;
66            assert_eq!(response.status(), StatusCode::NOT_FOUND, "{file}");
67        }
68    }
69
70    /// The htmx-config settings in `layout.html` are only half of the pair;
71    /// these rules are the other half, and dropping them leaves every htmx
72    /// indicator permanently visible.
73    #[test]
74    fn the_stylesheet_carries_the_indicator_rules_htmx_is_told_not_to_inject() {
75        assert!(ADMIN_CSS.contains(".htmx-indicator"));
76        assert!(ADMIN_CSS.contains(".htmx-request .htmx-indicator"));
77    }
78
79    #[test]
80    fn the_vendored_htmx_is_the_version_the_readme_records() {
81        assert!(HTMX_JS.contains(r#"version:"2.0.10""#));
82    }
83}