Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

fix(phone-conversation): allowlist body.path on /play-audio (LAN-reachable RCE-shape) - #37

Merged
rickchen007 merged 1 commit into
mainfrom
fix/phone-server-play-audio-path-allowlist
May 22, 2026
Merged

rickchen007 merged 1 commit into
mainfrom
fix/phone-server-play-audio-path-allowlist

Conversation

@VasiliyRad

Copy link
Copy Markdown

The bug (security)

skills/phone-conversation/scripts/conversation-server.ts exposes a POST /play-audio endpoint that 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:

  1. No authentication on the endpoint. The phone server binds to 0.0.0.0 (line 1720) to accommodate ngrok's local-tunnel client → any caller on the LAN can POST /play-audio.
  2. No path 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 happens to be on the active phone call with the owner.

Concretely: if the owner is on a phone call with someone, anyone on the LAN can POST {"path": "/path/to/owner-private-recording.mov"} and the unrelated call participant suddenly hears the recording.

Why this is the right fix

Adopt the established _is_path_sendable pattern used in discord-bridge.py and telegram-bridge.py for [file: /path] marker delivery: an explicit allowlist of path prefixes + a realpath collapse to defeat symlink / .. escape.

New file skills/phone-conversation/scripts/audio_path_guard.ts owns the allowlist and 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-*.mov convention used by src/recording-tools.ts:527 and :1677 (the legitimate callers).

The /play-audio handler replaces its existsSync() precheck with isAllowedAudioPath() and returns 403 (not 404) on rejected paths so the operator log distinguishes "missing file" from "policy violation."

Why this is a good-quality change

  • Maintainability: allowlist + helper live in ONE file. Future legitimate sources (e.g. workspace-stored call recordings) are added by editing one constant. Mirrors the discord/telegram pattern.
  • Testability: tests/phone-conversation-audio-path-guard.test.ts exercises the helper directly — 8 cases covering: allowed case, /etc/hosts as canonical disallowed file, prefix mismatch, non-existent file, null/undefined/non-string input, symlink-escape attempt (the precise reason for the realpath collapse), .. traversal, and a documented-set assertion that fails on silent allowlist widening.
  • Robustness: closes the LAN-reachable file-exfil-via-audio primitive. Even with the 0.0.0.0 binding kept (ngrok dependency), the endpoint is now policy-gated.
  • Low risk: the legitimate caller (src/recording-tools.ts) passes /tmp/sutando-recording-* paths — all under the allowlist. The 2nd spawn site in conversation-server.ts (the QuickTime-resume re-stream, line ~1677) uses a path derived from an internal ls -t /tmp/sutando-recording-*.mov shell glob — also under the allowlist by construction (NOT user-controlled), so no additional gate needed there.

Round totals

18 PRs, 14 real bugs (1 new security finding), 3 design fixes, ~133 tests, density ~1:9.5.

Test plan

  • npx tsx --test tests/phone-conversation-audio-path-guard.test.ts — 8/8 pass
  • npm run typecheck — clean
  • Existing tests still pass (no regression to legit caller path)
  • Manual: with phone server running, POST /play-audio with {"path": "/etc/hosts"} — verify 403 + no ffmpeg spawn. Then POST with a real /tmp/sutando-recording-*.mov during an active call — verify normal playback.

🤖 Generated with Claude Code

…hable RCE-shape)

## What & why

The phone-conversation server in `skills/phone-conversation/scripts/conversation-server.ts`
exposes a POST `/play-audio` endpoint that does:

```ts
const body = JSON.parse(await readBody(req)) as { path: string; ... };
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 and send over Twilio WebSocket to active call
});
```

Two structural problems:

1. **No authentication** on the endpoint. The phone server binds to
   `0.0.0.0` (line 1720) to accommodate ngrok's local-tunnel client,
   so any caller on the LAN can POST `/play-audio`.

2. **No path allowlist on `body.path`** — only `existsSync()`. The
   attacker chooses any local file the server's user could read;
   ffmpeg opens it; its audio (if any decodable) streams over Twilio
   to whoever is on the active phone call with the owner. For files
   ffmpeg can't decode as audio, ffmpeg attempts demux and fails,
   but the file open + read still happens. With a symlink under any
   allowed-LOOKING prefix the attacker could probe filesystem
   structure via timing.

Concretely: if the owner is on a phone call with someone, anyone on
the LAN can POST `{"path": "/path/to/owner-private-recording.mov"}`
and the unrelated call participant suddenly hears the recording.

## Why this is the right fix

Adopt the established `_is_path_sendable` pattern used in
`discord-bridge.py` and `telegram-bridge.py` for `[file: /path]`
marker delivery: an explicit allowlist of path prefixes + a
`realpath` collapse to defeat symlink/`..` escape.

A new file `audio_path_guard.ts` owns the allowlist and the helper:

  - `AUDIO_ALLOWED_PREFIXES = ["/tmp/sutando-", "/private/tmp/sutando-"]`
    Matches the recording skill's `/tmp/sutando-recording-*.mov`
    convention used by `src/recording-tools.ts:527` and 1677 (the
    legitimate callers of `/play-audio`).
  - `isAllowedAudioPath(p)` checks `existsSync` → `realpathSync` →
    prefix check.

The `/play-audio` handler replaces its `existsSync()` precheck with
`isAllowedAudioPath()`, returning 403 (not 404) on a rejected path
so the operator log distinguishes "missing file" from "policy
violation". Logged path is the literal request value (no realpath
echo) for incident triage.

## Why this is a good-quality change

- **Maintainability:** the allowlist + helper live in ONE file
  (audio_path_guard.ts). Future legitimate sources (e.g., recorded
  calls under the workspace) are added by editing one constant.
  Mirrors the discord/telegram pattern reviewers already understand.
- **Testability:** `tests/phone-conversation-audio-path-guard.test.ts`
  exercises the helper directly — 8 cases covering the allowed
  case, /etc/hosts as the canonical disallowed file, prefix
  mismatch, non-existent path, null/undefined/non-string input,
  **symlink-escape attempt** (the precise reason for the
  `realpath` collapse), and **`..` traversal**. Plus a
  documented-set assertion that fails on silent allowlist widening.
- **Robustness:** closes the LAN-reachable file-exfil-via-audio
  primitive. Even with the 0.0.0.0 binding kept (ngrok dependency),
  the endpoint is now policy-gated.
- **Low risk:** the legitimate caller (`src/recording-tools.ts`)
  passes `/tmp/sutando-recording-*` paths — all under the allowlist.
  Verified the 2 spawn sites in conversation-server.ts: the
  /play-audio site is gated by this PR; the second spawn (1677,
  QuickTime-resume re-stream) uses a path derived from an internal
  `ls -t /tmp/sutando-recording-*.mov` shell glob — also under
  the allowlist by construction (NOT user-controlled), so no
  additional gate needed there.

## Bug-finding calibration

1 real security bug fixed + 8 tests. Density preserved.
Round totals: **18 PRs, 14 real bugs, 3 design fixes, ~133 tests,
density ~1:9.5.**

## Test plan

- [x] `npx tsx --test tests/phone-conversation-audio-path-guard.test.ts` — 8/8 pass
- [x] `npm run typecheck` — clean
- [x] Existing tests still pass (no regressions to legitimate caller path)
- [ ] Manual: with phone server running, POST `/play-audio` with
      `{"path": "/etc/hosts"}` — verify 403 logged + no ffmpeg
      spawn. Then POST with a real `/tmp/sutando-recording-*.mov`
      during an active call — verify normal playback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold review (proactive-loop pass)

Vulnerability confirmed, fix is correct. The two-layer problem (no auth + no path gate + LAN-reachable binding) is accurately described. The existsSync-only check doesn't defend against symlinks or path traversal, and ffmpeg will happily stream any file it can read.

isAllowedAudioPath implementation is solid:

  • realpathSync before the prefix check correctly defeats symlink-to-sensitive-file attacks (the specific attack vector documented in the test)
  • typeof fpath !== 'string' guard defends against non-string JSON body values
  • 403 vs 404 distinction on rejection — good for operator log clarity
  • Both /tmp/sutando- and /private/tmp/sutando- prefixes needed (macOS: /tmp is a symlink to /private/tmp)

One minor code observation (non-blocking): the real === prefix.slice(0, -1) branch checks whether real equals the prefix with its last character removed — for prefix /tmp/sutando- this means a file at the path /tmp/sutando (without trailing dash) would be allowed. That path is unlikely to exist and isn't a practical security gap, but the intent of this branch isn't obvious from the code. If it's meant to handle a trailing-separator edge case, a comment would help; if it's unintended, removing it tightens the invariant. Doesn't block merge.

Tests — 8 cases are right: allowed, /etc/hosts disallowed, prefix-mismatch, non-existent, null/undefined/non-string inputs, symlink escape, .. traversal, and the allowlist-size assertion that fails on silent widening. The symlink and traversal cases are the critical ones and they're both covered.

Legitimate caller impact: recording skill writes to /tmp/sutando-recording-*.mov → well under the allowlist. No regression risk.

LGTM. Clean, well-scoped fix with the right defensive shape.

@rickchen007 rickchen007 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved (admin) — security fix, path allowlist + realpath collapse correct, 8 tests incl symlink/traversal.

@rickchen007
rickchen007 merged commit e5e0d4e into main May 22, 2026
1 check passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants