Repository navigation
fix(phone-conversation): allowlist body.path on /play-audio (LAN-reachable file-exfil shape) - #980
Conversation
…hable file-exfil-shape)
## The bug (security)
`skills/phone-conversation/scripts/conversation-server.ts` exposes
POST `/play-audio` which currently does:
if (!body.path) { json(res, 400, ...); return; }
if (!existsSync(body.path)) { json(res, 404, ...); return; }
// ... spawn ffmpeg with body.path as -i
const ffmpegProc = spawn('ffmpeg', [...seekArgs, '-re', '-i', body.path, ...]);
ffmpegProc.stdout.on('data', (pcmBuf) => {
// mu-law encode + stream over Twilio WebSocket to active phone call
});
Two structural problems compound:
1. The endpoint has no authentication. The phone server binds to
`0.0.0.0` (to accommodate ngrok's local-tunnel client), so any
caller on the LAN can POST `/play-audio`.
2. There's no allowlist on `body.path` — only `existsSync()`. The
attacker chooses any local file the server's user can read; ffmpeg
opens it; its audio (if any decodable track) streams over Twilio
to whoever is on the active phone call.
Concretely: if the owner is on a phone call, anyone on the LAN can
POST `{"path": "/path/to/owner-private-recording.mov"}` and the
unrelated call participant suddenly hears the recording.
## Fix
Adopt the established `_is_path_sendable` pattern used in
`discord-bridge.py` and `telegram-bridge.py` for `[file: /path]`
marker delivery: explicit allowlist of path prefixes + a `realpath`
collapse to defeat symlink / `..` escapes.
New file `skills/phone-conversation/scripts/audio_path_guard.ts`:
- `AUDIO_ALLOWED_PREFIXES = ["/tmp/sutando-", "/private/tmp/sutando-"]`
Matches the recording skill's `/tmp/sutando-recording-*.mov`
convention used by the legitimate callers
(`src/recording-tools.ts`).
- `isAllowedAudioPath(p)` checks `existsSync` -> `realpathSync` ->
prefix check.
The `/play-audio` handler replaces its `existsSync()` precheck with
`isAllowedAudioPath()` and returns 403 (not 404) on a rejected path,
so operator logs distinguish "missing file" from "policy violation."
Scope note: the second ffmpeg spawn site in conversation-server.ts
(QuickTime-resume re-stream around line ~1645) uses a path derived
from an internal `ls -t /tmp/sutando-recording-*.mov` shell glob —
NOT user-controlled — so no additional gate is needed there.
## Tests
`tests/phone-conversation-audio-path-guard.test.ts` (8 cases):
- allowed: file directly under /tmp/sutando-
- rejected: /etc/hosts (canonical disallowed)
- rejected: /tmp file with different prefix
- rejected: non-existent file (existsSync precheck)
- rejected: null / empty / undefined / non-string input
- rejected: symlink under allowed prefix pointing OUTSIDE the
allowlist (the precise reason for the realpath collapse)
- rejected: `..` traversal that escapes the allowed prefix
- architectural: `AUDIO_ALLOWED_PREFIXES` is the documented tight
set — a silent widening fails this case
All 8 pass locally; `npm run typecheck` clean. The two pre-existing
task-bridge test failures observed locally are `node:sqlite` import
errors unrelated to this change (verified by stashing the patch and
running upstream main directly — same failures).
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>
|
LGTM. The bug is genuinely bad — LAN-reachable file exfil during an active phone call. Fix mirrors the Both ffmpeg-spawn sites covered (the |
|
Hi @VasiliyRad — really nice batch, all 14 reviewed LGTM (the 2 already merged + 12 still open). Update: thanks for signing the CLA — all 12 open PRs now show You won't need to touch them again unless rebase produces conflicts. Thanks for the careful security/UX hardening pass — composability across PRs (e.g. #985 building on #990's refactor) was thoughtful. |
|
Thanks @sonichi — and thanks for handling the rebases on your side. Acknowledged on the merge order (#990 first so #985 picks up the consolidated The composability note is appreciated — I deliberately staged #985 after #990 so dm-result.py could import from the shared chunker/marker layer rather than re-defining drift-prone copies. Same reason #988 (proactive routing) depends on the Happy to send follow-ups if any of these surface gaps once they're on
Each is a contained PR. Just point me at the priority order if/when you want them. |
…hable file-exfil-shape) (sonichi#980) ## The bug (security) `skills/phone-conversation/scripts/conversation-server.ts` exposes POST `/play-audio` which currently does: if (!body.path) { json(res, 400, ...); return; } if (!existsSync(body.path)) { json(res, 404, ...); return; } // ... spawn ffmpeg with body.path as -i const ffmpegProc = spawn('ffmpeg', [...seekArgs, '-re', '-i', body.path, ...]); ffmpegProc.stdout.on('data', (pcmBuf) => { // mu-law encode + stream over Twilio WebSocket to active phone call }); Two structural problems compound: 1. The endpoint has no authentication. The phone server binds to `0.0.0.0` (to accommodate ngrok's local-tunnel client), so any caller on the LAN can POST `/play-audio`. 2. There's no allowlist on `body.path` — only `existsSync()`. The attacker chooses any local file the server's user can read; ffmpeg opens it; its audio (if any decodable track) streams over Twilio to whoever is on the active phone call. Concretely: if the owner is on a phone call, anyone on the LAN can POST `{"path": "/path/to/owner-private-recording.mov"}` and the unrelated call participant suddenly hears the recording. ## Fix Adopt the established `_is_path_sendable` pattern used in `discord-bridge.py` and `telegram-bridge.py` for `[file: /path]` marker delivery: explicit allowlist of path prefixes + a `realpath` collapse to defeat symlink / `..` escapes. New file `skills/phone-conversation/scripts/audio_path_guard.ts`: - `AUDIO_ALLOWED_PREFIXES = ["/tmp/sutando-", "/private/tmp/sutando-"]` Matches the recording skill's `/tmp/sutando-recording-*.mov` convention used by the legitimate callers (`src/recording-tools.ts`). - `isAllowedAudioPath(p)` checks `existsSync` -> `realpathSync` -> prefix check. The `/play-audio` handler replaces its `existsSync()` precheck with `isAllowedAudioPath()` and returns 403 (not 404) on a rejected path, so operator logs distinguish "missing file" from "policy violation." Scope note: the second ffmpeg spawn site in conversation-server.ts (QuickTime-resume re-stream around line ~1645) uses a path derived from an internal `ls -t /tmp/sutando-recording-*.mov` shell glob — NOT user-controlled — so no additional gate is needed there. ## Tests `tests/phone-conversation-audio-path-guard.test.ts` (8 cases): - allowed: file directly under /tmp/sutando- - rejected: /etc/hosts (canonical disallowed) - rejected: /tmp file with different prefix - rejected: non-existent file (existsSync precheck) - rejected: null / empty / undefined / non-string input - rejected: symlink under allowed prefix pointing OUTSIDE the allowlist (the precise reason for the realpath collapse) - rejected: `..` traversal that escapes the allowed prefix - architectural: `AUDIO_ALLOWED_PREFIXES` is the documented tight set — a silent widening fails this case All 8 pass locally; `npm run typecheck` clean. The two pre-existing task-bridge test failures observed locally are `node:sqlite` import errors unrelated to this change (verified by stashing the patch and running upstream main directly — same failures). Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
The bug (security)
skills/phone-conversation/scripts/conversation-server.tsexposes a POST/play-audioendpoint that currently does:```ts
if (!body.path) { json(res, 400, ...); return; }
if (!existsSync(body.path)) { json(res, 404, ...); return; }
// ... spawn ffmpeg with body.path as -i
const ffmpegProc = spawn('ffmpeg', [...seekArgs, '-re', '-i', body.path, ...]);
ffmpegProc.stdout.on('data', (pcmBuf) => {
// mu-law encode + stream over Twilio WebSocket to active phone call
});
```
Two structural problems compound:
0.0.0.0to accommodate ngrok's local-tunnel client → any caller on the LAN can POST/play-audio.body.path— onlyexistsSync(). The attacker chooses any local file the server's user can read; ffmpeg opens it; its audio (if any decodable track) streams over Twilio to whoever is on the active phone call.Concretely: if the owner is on a phone call, anyone on the LAN can POST
{"path": "/path/to/owner-private-recording.mov"}and the unrelated call participant suddenly hears the recording.Fix
Adopt the established
_is_path_sendablepattern used indiscord-bridge.pyandtelegram-bridge.pyfor[file: /path]marker delivery: explicit allowlist of path prefixes +realpathcollapse to defeat symlink /..escapes.New file
skills/phone-conversation/scripts/audio_path_guard.tsowns the allowlist + helper:```ts
export const AUDIO_ALLOWED_PREFIXES = ['/tmp/sutando-', '/private/tmp/sutando-'];
export function isAllowedAudioPath(fpath: string): boolean {
// existsSync -> realpathSync -> prefix check
}
```
Matches the recording skill's
/tmp/sutando-recording-*.movconvention used by the legitimate callers (src/recording-tools.ts).The
/play-audiohandler replaces itsexistsSync()precheck withisAllowedAudioPath()and returns 403 (not 404) on rejected paths so operator logs distinguish "missing file" from "policy violation."Scope note: the second ffmpeg spawn site in conversation-server.ts (QuickTime-resume re-stream, around line ~1645) uses a path derived from an internal
ls -t /tmp/sutando-recording-*.movshell glob — NOT user-controlled — so no additional gate is needed there.Tests
tests/phone-conversation-audio-path-guard.test.ts— 8 cases:realpathSync..traversalAll 8 pass locally.
npm run typecheckclean.Note on the 2 pre-existing failing tests observed locally (
task-bridge-format,task-bridge-isvoice): both fail withERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite. Verified these fail on untouched main too — local Node version doesn't exposenode:sqlite. Unrelated to this PR.Test plan
npx tsx --test tests/phone-conversation-audio-path-guard.test.ts— 8/8 passnpm run typecheck— clean/play-audiowith{"path": "/etc/hosts"}— verify 403 + no ffmpeg spawn. Then POST with a real/tmp/sutando-recording-*.movduring an active call — verify normal playback.Found while reviewing phone-conversation server. Happy to adjust scope, naming, or test surface if the maintainers prefer a different shape.
🤖 Generated with Claude Code