Repository navigation
fix(phone-conversation): gate non-/twilio control endpoints to loopback only - #41
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 and the Twilio webhook path). That makes EVERY endpoint LAN-reachable. `/twilio/*` paths validate Twilio's signature; the rest are internal control endpoints with NO authentication: - POST /call - originate an outbound Twilio call - POST /concurrent-call - originate a child call during a parent - POST /hangup - terminate an active call - POST /play-audio - stream audio through Twilio (PR #37 gated body.path, but the endpoint itself is still unauth'd) - POST /stop-audio - stop active playback - POST /meeting - start meeting-mode session - POST /meeting-approve - upgrade a 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 anyone disrupt active calls from the LAN. `/play-audio` plus the path-allowlist fix from PR #37 still allows arbitrary `/tmp/sutando-*` audio streaming 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 any endpoint dispatch, check if (path !== '/health' && !path.startsWith('/twilio/') && !isLoopback(req)) { json(res, 403, { error: 'control endpoints are loopback-only' }); return; } - `/twilio/*` is exempt: those paths validate Twilio's signature downstream (existing TWILIO_AUTH_TOKEN HMAC check). - `/health` is exempt: read-only and useful for LAN-side liveness checks (load balancer probes, monitoring). - Every other endpoint is loopback-only. Logs the rejected remote address to stderr so operators can see attempted exploit attempts. ## Why this is the right fix - Minimal blast radius: one predicate, one gate, before the dispatch table. Easy to audit. - Matches the 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 PR #31's `_is_safe_callback_url` fix). - Doesn't change the bind address — ngrok still works, Twilio webhooks still reach /twilio/* over the public tunnel. Alternative considered: switch the bind from `0.0.0.0` to `127.0.0.1` and route ngrok separately. Rejected because it changes the server's deployment shape and would break installs where ngrok isn't the chosen 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: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Cold review (proactive-loop pass)
Vulnerability description is accurate. POST /call with an arbitrary to triggering Twilio calls on the owner's account is a real harassment + cost vector. The other control endpoints (hangup, concurrent-call, meeting-mode) compound the surface.
isLoopback implementation — correct and minimal:
addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'Covers all three loopback forms that req.socket.remoteAddress can return in practice. The 127.0.0.0/8 range isn't needed here — Node.js's HTTP server always reports exactly 127.0.0.1 for loopback connections. Module extraction for testability is the right call given conversation-server.ts's heavy module-load side effects.
Gate placement (before dispatch table, after path parse) is correct — short-circuits before any business logic runs. The /twilio/* and /health exemptions are both justified: Twilio webhooks have their own HMAC validation; health probes are legitimately LAN-visible.
Logging on rejection (REJECTED non-loopback ${method} ${path} from ${remote}) is exactly what an operator needs for incident triage — remote address, method, and path in one line.
10 unit tests of the predicate + 4 source-grep architectural assertions — good split. The ::ffff:192.168.1.42 case (mapped non-loopback) is the one that could have been a false-positive if the implementation used IP prefix matching instead of explicit values.
One minor note (non-blocking): inline-tools.ts's control-endpoint callers use http://localhost:3100/ — which resolves to 127.0.0.1 on most systems, so they'll pass. Worth verifying on IPv6-only stacks where localhost may resolve to ::1; both forms are covered by the predicate, so this is already fine.
LGTM.
rickchen007
left a comment
There was a problem hiding this comment.
Approved (admin) — security fix, isLoopback predicate correct (covers IPv4/IPv6/v4-mapped loopback), gate placed before dispatch, /twilio + /health correctly exempt. 14 tests.
The bug (security — higher impact than PR #37)
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 calls (PR #37 gated the path but the endpoint itself was still unauth'd)Concrete 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.
Why this is the right 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 (the agent'sinline-tools.tsdoesfetch('http://localhost:3100/...')— verified 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:
isLoopbackfrom the guard moduleAll 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