{"id":"GHSA-54m3-5fxr-2f3j","summary":"Salvo is vulnerable to stored XSS in the list_html function by uploading files with malicious names","details":"# Summary\n\nThe function `list_html` generates a file view of a folder without sanitizing the files or folders names, potentially leading to XSS in cases where a website allows access to public files using this feature, allowing  anyone to upload a file.\n\n# Details\n\nThe vulnerable snippet of code is the following:\n[**dir.rs**](https://github.com/salvo-rs/salvo/blob/16efeba312a274739606ce76366d921768628654/crates/serve-static/src/dir.rs#L581)\n\n```rust\n// ... fn list_html(...\n        let mut link = \"\".to_owned();\n        format!(\n            r#\"\u003ca href=\"/\"\u003e{}\u003c/a\u003e{}\"#,\n            HOME_ICON,\n            segments\n                .map(|seg| {\n                    link = format!(\"{link}/{seg}\");\n                    format!(\"/\u003ca href=\\\"{link}\\\"\u003e{seg}\u003c/a\u003e\")\n                })\n                .collect::\u003cVec\u003c_\u003e\u003e()\n                .join(\"\")\n        )\n// ...\n```\n\n# PoC\n\nhttps://github.com/user-attachments/assets/1e161e17-f033-4cc4-855b-43fd38ed1be4\n\nHere is the example app we used:\n\n`mian.rs`\n```rs\nuse salvo::prelude::*;\nuse salvo::serve_static::StaticDir;\nuse std::path::PathBuf;\nuse tokio::fs;\n\nconst INDEX_HTML: &str = r#\"\u003c!doctype html\u003e\n\u003chtml\u003e\n  \u003chead\u003e\u003cmeta charset=\"utf-8\"\u003e\u003ctitle\u003eStaticDir PoC\u003c/title\u003e\u003c/head\u003e\n  \u003cbody\u003e\n    \u003ch2\u003eUpload a file\u003c/h2\u003e\n    \u003cform action=\"/upload\" method=\"post\" enctype=\"multipart/form-data\"\u003e\n      \u003cinput type=\"file\" name=\"file\" /\u003e\n      \u003cbutton type=\"submit\"\u003eUpload\u003c/button\u003e\n    \u003c/form\u003e\n\n    \u003cp\u003eBrowse uploads:\u003c/p\u003e\n    \u003cul\u003e\n      \u003cli\u003e\u003ca href=\"/files\"\u003e/files\u003c/a\u003e\u003c/li\u003e\n      \u003cli\u003e\u003ca href=\"/files/\"\u003e/files/\u003c/a\u003e\u003c/li\u003e\n    \u003c/ul\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n\"#;\n\n#[handler]\nasync fn index(res: &mut Response) {\n    res.render(Text::Html(INDEX_HTML));\n}\n\n#[handler]\nasync fn upload(req: &mut Request, res: &mut Response) {\n    fs::create_dir_all(\"uploads\").await.expect(\"create uploads dir\");\n\n    let form = match req.form_data().await {\n        Ok(v) =\u003e v,\n        Err(e) =\u003e {\n            res.status_code(StatusCode::BAD_REQUEST);\n            res.render(Text::Plain(format!(\"form_data parse failed: {e}\")));\n            return;\n        }\n    };\n\n    let Some(file_part) = form.files.get(\"file\") else {\n        res.status_code(StatusCode::BAD_REQUEST);\n        res.render(Text::Plain(\"missing file field (name=\\\"file\\\")\"));\n        return;\n    };\n\n    let original_name = file_part.name().unwrap_or(\"upload.bin\");\n\n    let mut dest = PathBuf::from(\"uploads\");\n    dest.push(original_name);\n\n    let tmp_path = file_part.path();\n    if let Err(e) = fs::copy(tmp_path, &dest).await {\n        res.status_code(StatusCode::INTERNAL_SERVER_ERROR);\n        res.render(Text::Plain(format!(\"save failed: {e}\")));\n        return;\n    }\n\n    res.render(Text::Plain(format!(\n        \"Uploaded as: {original_name}\\nNow open: http://127.0.0.1:5800/files/\\n\"\n    )));\n}\n\n#[tokio::main]\nasync fn main() {\n    tracing_subscriber::fmt().init();\n    fs::create_dir_all(\"uploads\").await.expect(\"create uploads dir\");\n\n    let router = Router::new()\n        .get(index)\n        .push(Router::with_path(\"upload\").post(upload))\n        .push(\n            Router::with_path(\"files/{**rest_path}\")\n                .get(StaticDir::new(\"uploads\").auto_list(true)),\n        );\n\n    let acceptor = TcpListener::new(\"127.0.0.1:5800\").bind().await;\n    Server::new(acceptor).serve(router).await;\n}\n```\n`Cargo.toml`\n```rs\n[package]\nname = \"poc\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nsalvo = { version = \"0.85.0\", features = [\"serve-static\"] }\ntokio = { version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"fs\"] }\ntracing-subscriber = \"0.3\"\n```\n# Impact\n\nJavaScript execution, most likely leading to an account takeover, depending on the site's constraint (CSP, etc…).","aliases":["CVE-2026-22257"],"modified":"2026-02-03T03:15:16.125543Z","published":"2026-01-08T21:16:41Z","database_specific":{"cwe_ids":["CWE-79"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-01-08T21:16:41Z","nvd_published_at":"2026-01-08T19:16:00Z"},"references":[{"type":"WEB","url":"https://github.com/salvo-rs/salvo/security/advisories/GHSA-54m3-5fxr-2f3j"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-22257"},{"type":"PACKAGE","url":"https://github.com/salvo-rs/salvo"},{"type":"WEB","url":"https://github.com/salvo-rs/salvo/blob/16efeba312a274739606ce76366d921768628654/crates/serve-static/src/dir.rs#L581"}],"affected":[{"package":{"name":"salvo","ecosystem":"crates.io","purl":"pkg:cargo/salvo"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.88.1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/01/GHSA-54m3-5fxr-2f3j/GHSA-54m3-5fxr-2f3j.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:L"}]}