GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

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…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…