GHSA-54M3-5FXR-2F3J
Vulnerability from github – Published: 2026-01-08 21:16 – Updated: 2026-01-08 21:37
VLAI?
Summary
Salvo is vulnerable to stored XSS in the list_html function by uploading files with malicious names
Details
Summary
The 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.
Details
The vulnerable snippet of code is the following: dir.rs
// ... fn list_html(...
let mut link = "".to_owned();
format!(
r#"<a href="/">{}</a>{}"#,
HOME_ICON,
segments
.map(|seg| {
link = format!("{link}/{seg}");
format!("/<a href=\"{link}\">{seg}</a>")
})
.collect::<Vec<_>>()
.join("")
)
// ...
PoC
https://github.com/user-attachments/assets/1e161e17-f033-4cc4-855b-43fd38ed1be4
Here is the example app we used:
mian.rs
use salvo::prelude::*;
use salvo::serve_static::StaticDir;
use std::path::PathBuf;
use tokio::fs;
const INDEX_HTML: &str = r#"<!doctype html>
<html>
<head><meta charset="utf-8"><title>StaticDir PoC</title></head>
<body>
<h2>Upload a file</h2>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<button type="submit">Upload</button>
</form>
<p>Browse uploads:</p>
<ul>
<li><a href="/files">/files</a></li>
<li><a href="/files/">/files/</a></li>
</ul>
</body>
</html>
"#;
#[handler]
async fn index(res: &mut Response) {
res.render(Text::Html(INDEX_HTML));
}
#[handler]
async fn upload(req: &mut Request, res: &mut Response) {
fs::create_dir_all("uploads").await.expect("create uploads dir");
let form = match req.form_data().await {
Ok(v) => v,
Err(e) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(Text::Plain(format!("form_data parse failed: {e}")));
return;
}
};
let Some(file_part) = form.files.get("file") else {
res.status_code(StatusCode::BAD_REQUEST);
res.render(Text::Plain("missing file field (name=\"file\")"));
return;
};
let original_name = file_part.name().unwrap_or("upload.bin");
let mut dest = PathBuf::from("uploads");
dest.push(original_name);
let tmp_path = file_part.path();
if let Err(e) = fs::copy(tmp_path, &dest).await {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(Text::Plain(format!("save failed: {e}")));
return;
}
res.render(Text::Plain(format!(
"Uploaded as: {original_name}\nNow open: http://127.0.0.1:5800/files/\n"
)));
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().init();
fs::create_dir_all("uploads").await.expect("create uploads dir");
let router = Router::new()
.get(index)
.push(Router::with_path("upload").post(upload))
.push(
Router::with_path("files/{**rest_path}")
.get(StaticDir::new("uploads").auto_list(true)),
);
let acceptor = TcpListener::new("127.0.0.1:5800").bind().await;
Server::new(acceptor).serve(router).await;
}
Cargo.toml
[package]
name = "poc"
version = "0.1.0"
edition = "2024"
[dependencies]
salvo = { version = "0.85.0", features = ["serve-static"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs"] }
tracing-subscriber = "0.3"
Impact
JavaScript execution, most likely leading to an account takeover, depending on the site's constraint (CSP, etc…).
Severity ?
8.8 (High)
{
"affected": [
{
"package": {
"ecosystem": "crates.io",
"name": "salvo"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.88.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-22257"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-01-08T21:16:41Z",
"nvd_published_at": "2026-01-08T19:16:00Z",
"severity": "HIGH"
},
"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: \u0026str = 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: \u0026mut Response) {\n res.render(Text::Html(INDEX_HTML));\n}\n\n#[handler]\nasync fn upload(req: \u0026mut Request, res: \u0026mut 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, \u0026dest).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\u0027s constraint (CSP, etc\u2026).",
"id": "GHSA-54m3-5fxr-2f3j",
"modified": "2026-01-08T21:37:13Z",
"published": "2026-01-08T21:16:41Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "Salvo is vulnerable to stored XSS in the list_html function by uploading files with malicious names"
}
Loading…
Loading…
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Loading…
Loading…