Skip to content

fix(subtitle): prevent ffmpeg protocol/argument injection in /api/subtitle - #144

Open
andesyteoss wants to merge 1 commit into
technomancer702:mainfrom
andesyteoss:fix/cwe78-subtitle-url-abb1
Open

andesyteoss wants to merge 1 commit into
technomancer702:mainfrom
andesyteoss:fix/cwe78-subtitle-url-abb1

Conversation

@andesyteoss

@andesyteoss andesyteoss commented Jul 23, 2026

Copy link
Copy Markdown

Summary

The GET /api/subtitle endpoint (server/routes/subtitle.js) passes the user-supplied url query parameter directly to ffmpeg as -i <url>, and interpolates index into -map 0:${index} without validation. Because ffmpeg supports a wide range of input protocols (file:, concat:, subfile:, data:, gopher:, etc.), an attacker who can reach this endpoint can coerce the server-side ffmpeg into reading arbitrary local files and streaming their contents back as WebVTT. The route is mounted in server/index.js (app.use('/api/subtitle', require('./routes/subtitle'))) with no authentication middleware in front of it, so exploitation is unauthenticated.

  • CWE-78 / CWE-88 – argument/protocol injection into an external command (ffmpeg).
  • Affected: server/routes/subtitle.js (router.get('/')).
  • Severity: High. Unauthenticated arbitrary local file read on the host running nodecast-tv. Common self-hosted deployments store credentials, session secrets, and IPTV provider tokens in files reachable by the server process.

Proof of concept

With a default local run of nodecast-tv (e.g. npm start or the Docker image) listening on port 3000, no login required:

# Read /etc/passwd via ffmpeg's file: protocol handler
curl -sS 'http://127.0.0.1:3000/api/subtitle?url=file:///etc/passwd&index=0'

# Read an arbitrary file by concatenating it as a fake input
curl -sS 'http://127.0.0.1:3000/api/subtitle?url=concat:/etc/hosts&index=0'

# Argument-token injection into -map via index
curl -sS 'http://127.0.0.1:3000/api/subtitle?url=http://example.invalid/x.mkv&index=0%20-map%200:v'

Before the fix, the first two return the file contents wrapped in a WebVTT envelope (or trigger ffmpeg errors that leak the content in stderr-derived responses). After the fix, all three return HTTP 400 with a clear validation error and ffmpeg is never invoked.

Fix

server/routes/subtitle.js:

  1. Parse url with the WHATWG URL constructor and reject anything whose protocol is not http: or https:.
  2. Coerce index to a number and require Number.isInteger(indexNum) && indexNum >= 0 with an exact string round-trip so values like 0 -map 0:v, 1e2, 0x1, or 1.5 are rejected.
  3. Add -protocol_whitelist http,https,tcp,tls,crypto to the ffmpeg argv as defense-in-depth, so even if a future code path forwards an attacker-influenced URL, ffmpeg itself refuses non-network protocols (and refuses to follow a redirect into e.g. file:).

Total diff: +26 / -3 in a single file, no behavior change for legitimate http(s) subtitle sources.

Testing

  • Manual: reproduced the file-read PoC against a local checkout at HEAD~1 (got /etc/passwd back as WebVTT cues), applied the patch, re-ran — all three PoC requests now return 400 with {"error":"Only http(s) URLs are allowed"} or {"error":"Index must be a non-negative integer"}.
  • Legit path: curl 'http://127.0.0.1:3000/api/subtitle?url=https://example.com/sample.mkv&index=2' still reaches ffmpeg with the expected -i https://example.com/sample.mkv -map 0:2 argv.
  • Verified the ffmpeg build shipped in the project's Dockerfile supports -protocol_whitelist (standard since ffmpeg 3.0).

Security analysis / adversarial review

Before submitting we tried to disprove this. Specifically:

  • Is there an auth gate we missed? server/index.js mounts the router with app.use('/api/subtitle', require('./routes/subtitle')). There is no app.use middleware that enforces authentication before /api/subtitle — the auth-protected routes rely on per-route ensureAuthenticated checks, and subtitle.js has none. So the unauthenticated-remote threat model holds.
  • Does ffmpeg refuse file: by default? No. Upstream ffmpeg builds enable the file protocol by default; it must be explicitly excluded via -protocol_whitelist, which is exactly what part (3) of the fix does.
  • Is index really injectable? Because Node's child_process.spawn is used (argv array, not a shell), classic shell metacharacters don't apply — but -map 0:${index} string-concatenates into a single argv token, and ffmpeg parses -map values loosely; combined with the surrounding argv it's enough to smuggle option-like tokens into subsequent parsing. Even setting that aside, the URL-side file read alone is high severity and independently sufficient to justify the fix.
  • Could the URL validation break legitimate use? The endpoint is only meaningful for network stream URLs (IPTV / HLS / MKV over HTTP); http(s)-only matches the documented usage.

m-kudahl added a commit to m-kudahl/nodecast-tv that referenced this pull request Sep 16, 2026
…T login

Upstream issues technomancer702#151, technomancer702#133, and the half of technomancer702#144 that was never finished.

technomancer702#151 - JWT and session cookies were signed with constants committed to the
repository ('nodecast-tv-secret-key-change-in-production' and 'keyboard
cat') whenever JWT_SECRET was unset, which is the default and was the case
here. A token signed with the published value authenticated as admin;
verified before and after, it now gets a 401. JWT_SECRET still wins if set,
otherwise a key is generated on first run into data/secret.key (0600,
already gitignored). Existing logins are invalidated once - passwords are
unaffected.

PR technomancer702#144 gave /api/subtitle a scheme check and a -protocol_whitelist, but
/api/probe, /api/remux and /api/transcode take the same url parameter,
hand it to the same binaries, and had the same hole - ffmpeg speaks file:,
concat: and subfile:, so an unauthenticated request could read files off
the server. One validator in server/safeUrl.js now covers all four, and the
whitelist is on every ffmpeg invocation including the session builder.
Confirmed: file:// reached ffprobe before, returns 400 on all four now, and
ordinary http URLs are unaffected.

technomancer702#133 - the login form had no method, so any native submit (JS not attached
yet, or an error before preventDefault) sent the password as a GET query
string into history and logs. It is method="post" now. Also deletes
public/js/login.js, which no page loaded and which referenced element ids
and a storage key that do not exist.
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