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…).

Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

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…

Detection rules are retrieved from Rulezet.

Loading…

Loading…