Skip to content

fix(server): prevent path traversal in voice_prompt query parameter - #101

Open
sebastionoss wants to merge 1 commit into
NVIDIA:mainfrom
sebastionoss:fix/cwe22-server-voice-9625
Open

fix(server): prevent path traversal in voice_prompt query parameter#101
sebastionoss wants to merge 1 commit into
NVIDIA:mainfrom
sebastionoss:fix/cwe22-server-voice-9625

Conversation

@sebastionoss

@sebastionoss sebastionoss commented Jul 19, 2026

Copy link
Copy Markdown

Summary

The /api/chat WebSocket handler in moshi/moshi/server.py accepts a voice_prompt query parameter and joins it directly onto self.voice_prompt_dir before passing it to load_voice_prompt / load_voice_prompt_embeddings. Both loaders call torch.load() on the resulting path. Because the value is unvalidated, an attacker who can reach the endpoint can:

  1. Read/probe arbitrary files by supplying ..-sequences or absolute paths (voice_prompt=../../etc/passwd), and
  2. Achieve remote code execution if any attacker-influenced .pt file exists anywhere on the filesystem — torch.load() unpickles by default, and pickle deserialization is a well-known RCE sink.
  • CWE: CWE-22 (Path Traversal), with escalation to CWE-502 (Deserialization of Untrusted Data) via torch.load.
  • Affected file/function: moshi/moshi/server.py, ServerState.handle_chat (around line 152).
  • Endpoint: GET /api/chat (registered at app.router.add_get("/api/chat", state.handle_chat)).
  • Auth: none — the route is registered with no auth middleware and no per-handler check.
  • Reachability: the server binds to localhost by default but supports --host 0.0.0.0 and ships a first-class --gradio-tunnel flag that publishes it to the public internet, so remote reachability is a documented deployment mode, not a hypothetical.

Data flow

request.query["voice_prompt"]                             (attacker-controlled)
  → os.path.join(self.voice_prompt_dir, voice_prompt_filename)   (no validation)
  → requested_voice_prompt_path
  → load_voice_prompt_embeddings(path)  /  load_voice_prompt(path)
  → torch.load(path)                                      (pickle → RCE sink)

Fix

Restrict voice_prompt to a bare filename inside voice_prompt_dir:

  1. Reject any value where os.path.basename(name) != name, contains os.sep/os.altsep, is absolute, or is . / ...
  2. Resolve both the configured directory and the candidate path with os.path.realpath and verify containment with os.path.commonpath, so symlinks pointing outside the directory are also rejected.
  3. On any violation, return HTTPBadRequest before touching the filesystem.

The check is defence-in-depth: the syntactic rejection catches the common attack strings early with a clear error, and the realpath + commonpath check is the authoritative containment guarantee.

Diff is 19 additions / 1 deletion in a single file. No behavioural change for legitimate callers that pass a plain filename such as default.pt.

Proof of concept

Start the server against a directory containing at least one .pt voice prompt (the normal deployment):

cd moshi
python -m moshi.server --host 0.0.0.0 --port 8998

Before the fix — the server joins the path unchecked and calls torch.load on it:

# Path traversal (arbitrary read/probe)
curl -i -N \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  "http://TARGET:8998/api/chat?voice_prompt=../../../../etc/hostname"

# RCE via pickle if any attacker-writable .pt exists anywhere:
#   python -c "import torch,os; torch.save({'x':1}, '/tmp/x.pt')"
#   then craft a pickle payload and point voice_prompt at it, e.g.
#   voice_prompt=../../../../tmp/evil.pt

After the fix, both requests fail fast with 400 Bad Request: Invalid voice_prompt name / Invalid voice_prompt path and never reach torch.load. A legitimate request such as ?voice_prompt=default.pt continues to work unchanged.

Testing

  • Verified the added checks reject ../x, /etc/passwd, ., .., foo/bar, and Windows-style ..\x.
  • Verified a legitimate bare filename (default.pt) still resolves and loads.
  • Confirmed via grep that /api/chat is the only route consuming voice_prompt, so no parallel unpatched sink exists.
  • Ran git diff main..HEAD --stat: change is confined to moshi/moshi/server.py (+19 / -1).

Adversarial review

Before submitting we tried to disprove this. Candidate reasons it might not matter:

  • "It's only bound to localhost." — Not true in practice: --host 0.0.0.0 is a supported CLI flag and --gradio-tunnel explicitly exposes the server to the public internet, both documented in argparse help text in server.py. Multi-tenant/remote is an intended deployment.
  • "There's auth in front of /api/chat." — There isn't. The route is registered directly on the aiohttp router with no middleware and no per-handler token check; greping the file confirms no @auth decorator or web.middleware gate.
  • "torch.load is safe now." — It still defaults to pickle-based loading in the torch versions this project targets, and even the file-existence probe alone is a CWE-22 information disclosure. The fix is worthwhile even ignoring the RCE path.

We could not find a mitigation that makes the unpatched code safe in the shipped deployment modes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant