Repository navigation
fix(phone-conversation): gate non-/twilio control endpoints to loopback only - #992
Conversation
…ck only
## The bug (security)
`skills/phone-conversation/scripts/conversation-server.ts` binds to
`0.0.0.0` to accommodate ngrok's local-tunnel client + the Twilio
webhook path. That makes EVERY endpoint LAN-reachable. /twilio/*
paths validate Twilio's signature; the rest are unauthenticated
control endpoints:
- POST /call - originate an outbound Twilio call
- POST /concurrent-call - originate child call during a parent
- POST /hangup - terminate an active call
- POST /play-audio - stream audio through Twilio
- POST /stop-audio - stop active playback
- POST /meeting - start meeting-mode session
- POST /meeting-approve - upgrade meeting to task-delegation
Concrete attack: anyone on the LAN POSTs:
curl -X POST http://<host>:3100/call \
-d '{"to":"+1xxx","message":"go"}'
Triggers an outbound Twilio call from the owner's account. This:
- costs money (Twilio per-call charges),
- reveals the owner's Twilio caller-ID,
- can be abused for harassment / robocall through the owner's
account,
- combined with /concurrent-call, allows spam-dialing multiple
targets in parallel.
`/hangup` and `/stop-audio` let any LAN caller disrupt active owner
calls. `/play-audio` lets them stream arbitrary `/tmp/sutando-*`
audio during the owner's calls.
## Fix
New module `loopback_guard.ts` exports `isLoopback(req)`, a one-line
predicate against `req.socket.remoteAddress` covering IPv4 loopback
(`127.0.0.1`), IPv6 loopback (`::1`), and the IPv4-mapped IPv6 form
(`::ffff:127.0.0.1`) that some Node builds report on dual-stack
sockets.
In the request handler, BEFORE the endpoint dispatch:
if (path !== '/health'
&& !path.startsWith('/twilio/')
&& !isLoopback(req)) {
json(res, 403, { error: 'control endpoints are loopback-only' });
return;
}
- /twilio/* exempt: validated downstream by TWILIO_AUTH_TOKEN HMAC
signature check.
- /health exempt: read-only liveness probe useful for LAN-side
monitoring.
- Everything else is loopback-only.
Logs the rejected remote address to stderr for operator triage.
## Why this is the right fix
- Minimal blast radius: one predicate, one gate, before the
dispatch table. Easy to audit.
- Matches existing in-codebase pattern (`_is_path_sendable`
realpath-collapse for files; this is the network-layer
equivalent).
- Defends against the IPv4-mapped IPv6 bypass class (same shape
as `_is_safe_callback_url` SSRF fix).
- Doesn't change the bind address — ngrok still works, Twilio
webhooks still reach /twilio/* over the public tunnel, /health
probes still work LAN-side.
Alternative considered: switch bind from 0.0.0.0 to 127.0.0.1 and
route ngrok separately. Rejected because it changes the deployment
shape and would break installs where ngrok isn't the tunneling
solution. The endpoint-level gate is cleaner.
## Tests
`tests/phone-conversation-loopback-guard.test.ts` — 14 cases:
10 direct unit tests of `isLoopback` against various
`remoteAddress` values (loopback v4/v6/mapped-v6 accepted; LAN v4,
public v4, link-local v6, ULA v6, IPv4-mapped IPv6 to NON-loopback,
empty/undefined all rejected).
4 source-grep architectural assertions on conversation-server.ts:
- imports isLoopback from the guard module
- gates non-/twilio non-/health endpoints with the predicate
- returns 403 (not 404 or silent) on the rejection path
- logs the rejected remote address for incident triage
All 14 pass locally. `npm run typecheck` clean.
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>
|
LGTM. Most directly weaponizable LAN attack in the batch — unauth'd Allowlist shape is correct:
|
…ck only (sonichi#992) ## The bug (security) `skills/phone-conversation/scripts/conversation-server.ts` binds to `0.0.0.0` to accommodate ngrok's local-tunnel client + the Twilio webhook path. That makes EVERY endpoint LAN-reachable. /twilio/* paths validate Twilio's signature; the rest are unauthenticated control endpoints: - POST /call - originate an outbound Twilio call - POST /concurrent-call - originate child call during a parent - POST /hangup - terminate an active call - POST /play-audio - stream audio through Twilio - POST /stop-audio - stop active playback - POST /meeting - start meeting-mode session - POST /meeting-approve - upgrade meeting to task-delegation Concrete attack: anyone on the LAN POSTs: curl -X POST http://<host>:3100/call \ -d '{"to":"+1xxx","message":"go"}' Triggers an outbound Twilio call from the owner's account. This: - costs money (Twilio per-call charges), - reveals the owner's Twilio caller-ID, - can be abused for harassment / robocall through the owner's account, - combined with /concurrent-call, allows spam-dialing multiple targets in parallel. `/hangup` and `/stop-audio` let any LAN caller disrupt active owner calls. `/play-audio` lets them stream arbitrary `/tmp/sutando-*` audio during the owner's calls. ## Fix New module `loopback_guard.ts` exports `isLoopback(req)`, a one-line predicate against `req.socket.remoteAddress` covering IPv4 loopback (`127.0.0.1`), IPv6 loopback (`::1`), and the IPv4-mapped IPv6 form (`::ffff:127.0.0.1`) that some Node builds report on dual-stack sockets. In the request handler, BEFORE the endpoint dispatch: if (path !== '/health' && !path.startsWith('/twilio/') && !isLoopback(req)) { json(res, 403, { error: 'control endpoints are loopback-only' }); return; } - /twilio/* exempt: validated downstream by TWILIO_AUTH_TOKEN HMAC signature check. - /health exempt: read-only liveness probe useful for LAN-side monitoring. - Everything else is loopback-only. Logs the rejected remote address to stderr for operator triage. ## Why this is the right fix - Minimal blast radius: one predicate, one gate, before the dispatch table. Easy to audit. - Matches existing in-codebase pattern (`_is_path_sendable` realpath-collapse for files; this is the network-layer equivalent). - Defends against the IPv4-mapped IPv6 bypass class (same shape as `_is_safe_callback_url` SSRF fix). - Doesn't change the bind address — ngrok still works, Twilio webhooks still reach /twilio/* over the public tunnel, /health probes still work LAN-side. Alternative considered: switch bind from 0.0.0.0 to 127.0.0.1 and route ngrok separately. Rejected because it changes the deployment shape and would break installs where ngrok isn't the tunneling solution. The endpoint-level gate is cleaner. ## Tests `tests/phone-conversation-loopback-guard.test.ts` — 14 cases: 10 direct unit tests of `isLoopback` against various `remoteAddress` values (loopback v4/v6/mapped-v6 accepted; LAN v4, public v4, link-local v6, ULA v6, IPv4-mapped IPv6 to NON-loopback, empty/undefined all rejected). 4 source-grep architectural assertions on conversation-server.ts: - imports isLoopback from the guard module - gates non-/twilio non-/health endpoints with the predicate - returns 403 (not 404 or silent) on the rejection path - logs the rejected remote address for incident triage All 14 pass locally. `npm run typecheck` clean. Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
The bug (security)
skills/phone-conversation/scripts/conversation-server.tsbinds to0.0.0.0to accommodate ngrok's local-tunnel client + the Twilio webhook path. That makes every endpoint LAN-reachable./twilio/*paths validate Twilio's signature; the rest are unauthenticated control endpoints:/tmp/sutando-*audio during owner's active callsConcrete repro:
```bash
curl -X POST http://:3100/call
-d '{"to":"+1xxxxxxxxxx","message":"go"}'
```
Triggers an outbound call from the owner's Twilio account. No auth check, no rate limit, no logging of the caller.
Fix
New
loopback_guard.tsexportsisLoopback(req)— a one-line predicate againstreq.socket.remoteAddresscovering IPv4 (127.0.0.1), IPv6 (::1), and IPv4-mapped IPv6 loopback (::ffff:127.0.0.1).In the request handler, BEFORE the endpoint dispatch:
```ts
if (path !== '/health'
&& !path.startsWith('/twilio/')
&& !isLoopback(req)) {
json(res, 403, { error: 'control endpoints are loopback-only' });
return;
}
```
/twilio/*exempt — validated downstream byTWILIO_AUTH_TOKENHMAC signature check./healthexempt — read-only liveness probe.Logs the rejected remote address to stderr for operator triage.
Alternative considered: switch bind from
0.0.0.0to127.0.0.1and route ngrok separately. Rejected because it changes the server's deployment shape — would break installs where ngrok isn't the tunneling solution. The endpoint-level gate is cleaner and preserves the deployment surface.Why this is a good-quality change
_is_path_sendablefor files; this is the network-layer equivalent).isLoopbackextracted to its own tiny module so unit tests don't have to load conversation-server.ts (which has heavy module-load side effects)./twilio/*over the public tunnel./healthLAN-side probes still work. Legit owner-side callers always hit from loopback.Tests
tests/phone-conversation-loopback-guard.test.ts— 14 cases:10 unit tests of
isLoopback:127.0.0.1::1::ffff:127.0.0.1(IPv4-mapped IPv6)192.168.1.42(LAN v4)10.0.0.5(private v4)1.1.1.1(public v4)fe80::1(link-local v6)fc00::1(ULA v6)::ffff:192.168.1.42(mapped non-loopback v4)""/undefined4 source-grep architectural assertions on conversation-server.ts confirming the gate is wired in, returns 403, logs the rejected remote, and imports from the guard module.
All 14 pass locally.
npm run typecheckclean.Test plan
npx tsx --test tests/phone-conversation-loopback-guard.test.ts— 14/14 passcurl -X POST http://<owner-host>:3100/call -d '{"to":"+1xxx","message":"x"}'— verify 403 + log. Then from localhost — verify normal handling.🤖 Generated with Claude Code