diff --git a/README.md b/README.md index 53e3b46..e803eff 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,17 @@ Prints a migration-complexity score and a per-feature *works-as-is / heads-up / **Run the adapter** (Node 20+): ```bash npm start # adapter on :3000 -npm test # 265 tests +npm test # 303 tests npm run typecheck ``` | Env var | Meaning | |---|---| | `ADAPTER_ACCOUNT_SID` / `ADAPTER_AUTH_TOKEN` | What the customer's Twilio SDK + webhook-signature validation use | +| `WEBHOOK_USER` / `WEBHOOK_PASSWORD` | Basic-auth creds Bandwidth presents on inbound `/bw/*` webhooks; set the same as your Voice app's `CallbackCreds` | +| `HOST` | Listen interface (default `127.0.0.1`); set `0.0.0.0` for containers/exposed deployments | +| `EGRESS_ALLOW_PRIVATE` | Set `1` to allow outbound fetches to private/loopback ranges (local dev only) | +| `EGRESS_ALLOW_HOSTS` | Optional comma-separated host allowlist; when set, outbound fetches are restricted to exactly these hosts (default-deny) | | `PUBLIC_BASE_URL` | Public HTTPS base of this adapter | | `CUSTOMER_VOICE_URL` | The customer's Twilio voice webhook (inbound calls) | | `BW_ACCOUNT_ID` / `BW_CLIENT_ID` / `BW_CLIENT_SECRET` / `BW_APPLICATION_ID` | Bandwidth credentials (OAuth2 client-credentials) — shared by Voice and the number facade | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ee604d0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security + +The adapter authenticates in both directions and constrains where it will send outbound requests. + +- **Outbound to Bandwidth** — OAuth2 client credentials (`BW_CLIENT_ID`/`BW_CLIENT_SECRET`). +- **Inbound webhooks (`/bw/*`)** — HTTP Basic auth. Set `WEBHOOK_USER`/`WEBHOOK_PASSWORD`, and configure the **same** credentials as `CallbackCreds` on your Bandwidth Voice-V2 application. The adapter also stamps them onto the BXML callback verbs it emits, so Bandwidth presents them on continuation callbacks. Requests to `/bw/*` without valid credentials are rejected. +- **REST facade (`/2010-04-01/*`)** — HTTP Basic auth using `ADAPTER_ACCOUNT_SID`/`ADAPTER_AUTH_TOKEN` (the Twilio-compat credential). This is a distinct secret from the webhook credential above. +- **Outbound fetches** — the adapter only fetches customer callback URLs over http/https and refuses loopback/link-local/private destinations. For local development against `localhost`, set `EGRESS_ALLOW_PRIVATE=1`. + For fixed, known customer hosts you may set `EGRESS_ALLOW_HOSTS` (comma-separated) to switch outbound fetches to a strict default-deny allowlist — the tightest posture ("full remediation"). Listed hosts are trusted and bypass the range check, so this also lets you allow `localhost` explicitly in development. + Note: the range check above resolves the hostname and validates the resulting IPs before the fetch, but it does not pin the connection to those IPs — the fetch itself re-resolves the hostname. That means it's defense-in-depth (the inbound Basic auth on `/bw/*` is the primary control) and does not defend against DNS rebinding, where a low-TTL hostname answers with a public IP at check time and an internal one at connect time. Deployments serving untrusted tenants should use `EGRESS_ALLOW_HOSTS` or pin the resolved IP at connect time. + +## Deployment + +- Serve over HTTPS. Basic-auth credentials are only as safe as the transport. +- The listen host defaults to `127.0.0.1`. To expose the adapter (containers, production), set `HOST=0.0.0.0` deliberately. +- Do not expose the adapter to the internet without the webhook credentials configured. + +## Reporting + +Report suspected vulnerabilities to security@bandwidth.com. diff --git a/scripts/bench-adapter-overhead.ts b/scripts/bench-adapter-overhead.ts index f671843..087f77c 100644 --- a/scripts/bench-adapter-overhead.ts +++ b/scripts/bench-adapter-overhead.ts @@ -52,12 +52,16 @@ const stub = createServer((req, res) => { }); // ─── the adapter under test (its /bw/initiate path never touches bwClient) ─── +const webhookUser = "bench-user"; +const webhookPassword = "bench-pass"; const app = buildApp( { accountSid: "ACbench", authToken: "benchtoken", publicBaseUrl: "http://127.0.0.1", voiceUrl: "http://127.0.0.1/unused", + webhookUser, + webhookPassword, }, { fetchImpl: fetch, bwClient: {} as unknown as BwClient }, ); @@ -87,7 +91,10 @@ async function timeAdapter(doc: string, i: number): Promise { const t = performance.now(); const r = await fetch(`${adapterBase}/bw/initiate?voiceUrl=${voiceUrl}`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + authorization: "Basic " + Buffer.from(`${webhookUser}:${webhookPassword}`).toString("base64"), + }, // Unique callId per iteration so the query voiceUrl wins over a stored record. body: JSON.stringify({ eventType: "initiate", diff --git a/src/server/app.ts b/src/server/app.ts index bf5eeba..089ed24 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -9,6 +9,7 @@ import { recordingStatusParams, postToCustomer, } from "../twilio/egress.js"; +import { EgressBlockedError } from "../twilio/egress-guard.js"; import { toCallSid, toRecordingSid, toIncomingPhoneNumberSid } from "../twilio/call-sid.js"; import { createdCallResource, bwStateToTwilioStatus, twilioErrors } from "../twilio/call-resource.js"; import { @@ -23,6 +24,7 @@ import type { BwClient } from "../bw/client.js"; import type { NumbersClient } from "../numbers/client.js"; import { translateSearchParams, translatePurchaseToOrder } from "../numbers/translate.js"; import { TwilioSearchParamsSchema, TwilioPurchaseParamsSchema } from "../numbers/schema.js"; +import { safeEqual } from "./safe-equal.js"; export interface AdapterConfig { accountSid: string; @@ -38,6 +40,13 @@ export interface AdapterConfig { siteId: string; peerId?: string; }; + /** Allow outbound fetches to private/loopback ranges (local dev). Default false. */ + allowPrivateEgress?: boolean; + /** Opt-in egress allowlist of expected customer hosts. Empty/undefined → range denylist applies. */ + egressAllowHosts?: string[]; + /** Basic-auth credentials Bandwidth presents on inbound webhooks (must match the app's CallbackCreds). */ + webhookUser: string; + webhookPassword: string; } export interface AdapterDeps { @@ -74,11 +83,23 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta // Logging off by default; set ADAPTER_LOG=1 to enable request/error logs. const app = Fastify({ logger: process.env.ADAPTER_LOG === "1" }); app.register(formbody); + + const expectedWebhookAuth = + "Basic " + Buffer.from(`${config.webhookUser}:${config.webhookPassword}`).toString("base64"); + // Bandwidth authenticates its inbound webhooks with Basic auth (CallbackCreds + // on the Voice app + username/password on the BXML callback verbs we emit). + app.addHook("onRequest", async (req, reply) => { + if (!req.url.startsWith("/bw/")) return; + if (!safeEqual(req.headers.authorization ?? "", expectedWebhookAuth)) { + return reply.code(401).header("WWW-Authenticate", "Basic").send({ error: "unauthorized" }); + } + }); + const store = new CallStore(); const expectedAuth = "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64"); - const authOk = (req: FastifyRequest) => (req.headers.authorization ?? "") === expectedAuth; + const authOk = (req: FastifyRequest) => safeEqual(req.headers.authorization ?? "", expectedAuth); const rewriter = (base: string) => (url: string, kind: UrlKind) => { const absolute = new URL(url, base).toString(); @@ -111,14 +132,28 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta // not ours) and the TwiML→BXML translation tax (CPU, ours). With ADAPTER_LOG=1 // each turn logs both; see `npm run bench` for the translation tax in isolation. const fetchStart = performance.now(); - const twiml = await postToCustomer({ - url: customerUrl, - params, - authToken: config.authToken, - fetchImpl: deps.fetchImpl, - }); + let twiml: string; + try { + twiml = await postToCustomer({ + url: customerUrl, + params, + authToken: config.authToken, + fetchImpl: deps.fetchImpl, + allowPrivate: config.allowPrivateEgress, + allowHosts: config.egressAllowHosts, + }); + } catch (err) { + if (err instanceof EgressBlockedError) { + app.log.error({ customerUrl, err }, "egress blocked"); + return reply.code(502).send({ error: "blocked egress target" }); + } + throw err; + } const translateStart = performance.now(); - const result = translateTwiml(twiml, { rewriteUrl: rewriter(customerUrl) }); + const result = translateTwiml(twiml, { + rewriteUrl: rewriter(customerUrl), + callbackAuth: { username: config.webhookUser, password: config.webhookPassword }, + }); app.log.info( { customerUrl, @@ -195,6 +230,8 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta params, authToken: config.authToken, fetchImpl: deps.fetchImpl, + allowPrivate: config.allowPrivateEgress, + allowHosts: config.egressAllowHosts, }); } catch (err) { app.log.error({ callId: event.callId, err }, "status callback POST failed"); @@ -235,6 +272,8 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta params, authToken: config.authToken, fetchImpl: deps.fetchImpl, + allowPrivate: config.allowPrivateEgress, + allowHosts: config.egressAllowHosts, }); } catch (err) { app.log.error({ callId: event.callId, err }, "recording status callback POST failed"); @@ -244,10 +283,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta }); app.post("/2010-04-01/Accounts/:accountSid/Calls.json", async (req, reply) => { - const header = req.headers.authorization ?? ""; - const expected = - "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64"); - if (header !== expected) return reply.code(401).send(twilioErrors.auth401); + if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); const body = req.body as Record; const { To, From, Url } = body; // Validation order matches live Twilio: To, then Url, then From. @@ -277,10 +313,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta app.get( "/2010-04-01/Accounts/:accountSid/Calls/:callSid/Recordings.json", async (req, reply) => { - const header = req.headers.authorization ?? ""; - const expected = - "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64"); - if (header !== expected) return reply.code(401).send(twilioErrors.auth401); + if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); const { callSid } = req.params as { callSid: string }; const record = store.getBySid(callSid); if (!record) return reply.code(404).send(twilioErrors.notFound(config.accountSid, callSid)); @@ -296,10 +329,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta ); app.get("/2010-04-01/Accounts/:accountSid/Recordings/:recordingSid.json", async (req, reply) => { - const header = req.headers.authorization ?? ""; - const expected = - "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64"); - if (header !== expected) return reply.code(401).send(twilioErrors.auth401); + if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); const { recordingSid } = req.params as { recordingSid: string }; const ref = store.getRecording(recordingSid); if (!ref) return reply.code(404).send(twilioErrors.notFound(config.accountSid, recordingSid)); @@ -310,10 +340,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta // Twilio serves recording audio at .../Recordings/RE....{mp3,wav}; both map to // the same BW media stream (BW returns the format the recording is stored in). const mediaHandler = async (req: FastifyRequest, reply: FastifyReply) => { - const header = req.headers.authorization ?? ""; - const expected = - "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64"); - if (header !== expected) return reply.code(401).send(twilioErrors.auth401); + if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); const { recordingSid } = req.params as { recordingSid: string }; const ref = store.getRecording(recordingSid); if (!ref) return reply.code(404).send(twilioErrors.notFound(config.accountSid, recordingSid)); @@ -329,10 +356,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta app.post( "/2010-04-01/Accounts/:accountSid/Calls/:callSid/Recordings/:recordingSid.json", async (req, reply) => { - const header = req.headers.authorization ?? ""; - const expected = - "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64"); - if (header !== expected) return reply.code(401).send(twilioErrors.auth401); + if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); const { recordingSid } = req.params as { recordingSid: string }; const ref = store.getRecording(recordingSid); if (!ref) return reply.code(404).send(twilioErrors.notFound(config.accountSid, recordingSid)); @@ -350,10 +374,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta ); app.get("/2010-04-01/Accounts/:accountSid/Calls/:callSid.json", async (req, reply) => { - const header = req.headers.authorization ?? ""; - const expected = - "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64"); - if (header !== expected) return reply.code(401).send(twilioErrors.auth401); + if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); const { callSid } = req.params as { callSid: string }; const record = store.getBySid(callSid); if (!record) return reply.code(404).send(twilioErrors.notFound(config.accountSid, callSid)); @@ -380,10 +401,7 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta }); app.post("/2010-04-01/Accounts/:accountSid/Calls/:callSid.json", async (req, reply) => { - const header = req.headers.authorization ?? ""; - const expected = - "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64"); - if (header !== expected) return reply.code(401).send(twilioErrors.auth401); + if (!authOk(req)) return reply.code(401).send(twilioErrors.auth401); const { callSid } = req.params as { callSid: string }; const record = store.getBySid(callSid); if (!record) return reply.code(404).send(twilioErrors.notFound(config.accountSid, callSid)); diff --git a/src/server/index.ts b/src/server/index.ts index 045e560..8d36985 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -33,6 +33,10 @@ const app = buildApp( authToken: env("ADAPTER_AUTH_TOKEN"), publicBaseUrl: env("PUBLIC_BASE_URL"), voiceUrl: env("CUSTOMER_VOICE_URL"), + webhookUser: env("WEBHOOK_USER"), + webhookPassword: env("WEBHOOK_PASSWORD"), + allowPrivateEgress: process.env.EGRESS_ALLOW_PRIVATE === "1", + egressAllowHosts: process.env.EGRESS_ALLOW_HOSTS?.split(",").map((s) => s.trim()).filter(Boolean), ...(siteId ? { numbers: { siteId, peerId: optEnv("BW_PEER_ID") } } : {}), }, { @@ -49,4 +53,5 @@ const app = buildApp( ); const port = Number(process.env.PORT ?? 3000); -app.listen({ port, host: "0.0.0.0" }).then(() => console.log(`adapter listening on :${port}`)); +const host = process.env.HOST ?? "127.0.0.1"; +app.listen({ port, host }).then(() => console.log(`adapter listening on ${host}:${port}`)); diff --git a/src/server/safe-equal.ts b/src/server/safe-equal.ts new file mode 100644 index 0000000..3821693 --- /dev/null +++ b/src/server/safe-equal.ts @@ -0,0 +1,11 @@ +import { timingSafeEqual } from "node:crypto"; + +/** + * Constant-time string comparison. Length is guarded first because + * timingSafeEqual throws on buffers of differing length. + */ +export function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} diff --git a/src/translator/translate.ts b/src/translator/translate.ts index 920ba14..074e711 100644 --- a/src/translator/translate.ts +++ b/src/translator/translate.ts @@ -14,6 +14,9 @@ export interface Finding { export interface TranslateOptions { rewriteUrl?: (url: string, kind: UrlKind) => string; + /** Basic-auth credentials to stamp onto emitted BXML callback verbs so Bandwidth + * authenticates its continuation callbacks (e.g. /bw/continue). */ + callbackAuth?: { username: string; password: string }; } export interface TranslateResult { @@ -162,6 +165,35 @@ function mapVoice(twilioVoice: string): string | null { return null; // unrecognized — drop rather than break the call } +// Attrs that carry a rewritten adapter callback URL (Bandwidth will hit these +// endpoints directly, so they need Basic-auth creds matching the app's CallbackCreds). +const CALLBACK_URL_ATTRS = [ + "gatherUrl", + "redirectUrl", + "recordCompleteUrl", + "recordingAvailableUrl", + "transferCompleteUrl", + "referCompleteUrl", +] as const; + +/** Walks the built element tree and stamps username/password onto any element + * carrying a rewritten adapter callback URL, so Bandwidth Basic-auths the + * continuation request instead of hitting it unauthenticated. */ +function stampCallbackAuth(els: XmlEl[], auth: { username: string; password: string }): void { + for (const el of els) { + if (el.attrs && CALLBACK_URL_ATTRS.some((a) => el.attrs![a] !== undefined)) { + el.attrs.username = auth.username; + el.attrs.password = auth.password; + } + if (el.children) { + const childEls = el.children.filter( + (c): c is XmlEl => typeof c !== "string" && !("raw" in c), + ); + stampCallbackAuth(childEls, auth); + } + } +} + export function translateTwiml(twiml: string, opts: TranslateOptions = {}): TranslateResult { const root = parseTwiml(twiml); const findings: Finding[] = []; @@ -171,6 +203,7 @@ export function translateTwiml(twiml: string, opts: TranslateOptions = {}): Tran const el = translateVerb(node, findings, rewrite); if (el) els.push(...el); } + if (opts.callbackAuth) stampCallbackAuth(els, opts.callbackAuth); return { bxml: bxmlDocument(els), findings, diff --git a/src/twilio/egress-guard.ts b/src/twilio/egress-guard.ts new file mode 100644 index 0000000..e028723 --- /dev/null +++ b/src/twilio/egress-guard.ts @@ -0,0 +1,93 @@ +import { lookup as dnsLookup } from "node:dns/promises"; +import { isIP, BlockList } from "node:net"; + +export class EgressBlockedError extends Error {} + +const blocks = new BlockList(); +// IPv4 special-use ranges that must never be reachable via customer-supplied URLs. +blocks.addSubnet("0.0.0.0", 8, "ipv4"); +blocks.addSubnet("10.0.0.0", 8, "ipv4"); +blocks.addSubnet("100.64.0.0", 10, "ipv4"); // CGNAT +blocks.addSubnet("127.0.0.0", 8, "ipv4"); // loopback +blocks.addSubnet("169.254.0.0", 16, "ipv4"); // link-local (cloud metadata) +blocks.addSubnet("172.16.0.0", 12, "ipv4"); +blocks.addSubnet("192.168.0.0", 16, "ipv4"); +blocks.addSubnet("192.0.0.0", 24, "ipv4"); +blocks.addSubnet("198.18.0.0", 15, "ipv4"); // benchmarking +blocks.addSubnet("224.0.0.0", 4, "ipv4"); // multicast +blocks.addSubnet("240.0.0.0", 4, "ipv4"); // reserved +blocks.addAddress("255.255.255.255", "ipv4"); +// IPv6 +blocks.addAddress("::1", "ipv6"); // loopback +blocks.addAddress("::", "ipv6"); // unspecified +blocks.addSubnet("fc00::", 7, "ipv6"); // ULA +blocks.addSubnet("fe80::", 10, "ipv6"); // link-local + +/** Decode ::ffff:a.b.c.d IPv4-mapped IPv6 to its embedded IPv4, else null. */ +function mappedV4(ip: string): string | null { + const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip); + return m ? m[1] : null; +} + +export function isBlockedAddress(ip: string): boolean { + const v4 = mappedV4(ip); + if (v4) return isBlockedAddress(v4); + const family = isIP(ip); + if (family === 4) return blocks.check(ip, "ipv4"); + if (family === 6) return blocks.check(ip, "ipv6"); + return true; // not a parseable IP → fail closed +} + +export async function assertPublicUrl( + rawUrl: string, + opts: { allowPrivate?: boolean; allowHosts?: string[]; lookup?: (host: string) => Promise } = {}, +): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new EgressBlockedError(`Malformed URL: ${rawUrl}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new EgressBlockedError(`Disallowed scheme: ${url.protocol}`); + + // Opt-in host allowlist ("full remediation"): default-deny by host. Listed + // hosts are explicitly trusted, so they bypass the range denylist below. + if (opts.allowHosts && opts.allowHosts.length > 0) { + const host = url.hostname.toLowerCase(); + if (!opts.allowHosts.some((h) => h.toLowerCase() === host)) + throw new EgressBlockedError(`Host not on egress allowlist: ${url.hostname}`); + return url; + } + + if (opts.allowPrivate) return url; + + const resolve = + opts.lookup ?? + (async (host: string) => (await dnsLookup(host, { all: true })).map((a) => a.address)); + + // Strip brackets from IPv6 literals: new URL("http://[::1]/").hostname === "[::1]", + // and net.isIP() does not accept brackets, so classification would otherwise + // fall through to DNS resolution instead of the IP fast path. + const host = url.hostname.replace(/^\[|\]$/g, ""); + + let literal: string[]; + if (isIP(host)) { + literal = [host]; + } else { + try { + literal = await resolve(url.hostname); + } catch { + throw new EgressBlockedError(`Cannot resolve host: ${url.hostname}`); + } + } + if (literal.length === 0) throw new EgressBlockedError(`Cannot resolve host: ${url.hostname}`); + for (const addr of literal) if (isBlockedAddress(addr)) + throw new EgressBlockedError(`Blocked egress target ${url.hostname} -> ${addr}`); + // NOTE: this only validates the IP(s) resolved here — it does not pin the + // connection to them. The caller's fetch() re-resolves the hostname on its + // own, so a low-TTL DNS answer could differ between this check and the + // actual connect. This is defense-in-depth, not a hard guarantee against + // DNS rebinding; the primary control is inbound Basic auth on `/bw/*`. + return url; +} diff --git a/src/twilio/egress.ts b/src/twilio/egress.ts index b0d5d68..a5aebac 100644 --- a/src/twilio/egress.ts +++ b/src/twilio/egress.ts @@ -1,4 +1,5 @@ import { twilioSignature } from "./signature.js"; +import { assertPublicUrl } from "./egress-guard.js"; import type { CallRecord } from "../server/call-store.js"; // Twilio's real voice webhooks (verified by live capture, see @@ -112,16 +113,33 @@ export async function postToCustomer(opts: { params: Record; authToken: string; fetchImpl?: typeof fetch; + allowPrivate?: boolean; + allowHosts?: string[]; + lookup?: (host: string) => Promise; + timeoutMs?: number; }): Promise { const doFetch = opts.fetchImpl ?? fetch; - const res = await doFetch(opts.url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "X-Twilio-Signature": twilioSignature(opts.authToken, opts.url, opts.params), - }, - body: new URLSearchParams(opts.params).toString(), - }); - if (!res.ok) throw new Error(`Customer webhook ${opts.url} returned ${res.status}`); - return await res.text(); + // Validate + resolve BEFORE any network call. Throws EgressBlockedError on a + // disallowed destination. + await assertPublicUrl(opts.url, { allowPrivate: opts.allowPrivate, allowHosts: opts.allowHosts, lookup: opts.lookup }); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? 10_000); + try { + const res = await doFetch(opts.url, { + method: "POST", + redirect: "manual", // a public URL 302-ing to an internal one is the classic bypass + signal: ac.signal, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Twilio-Signature": twilioSignature(opts.authToken, opts.url, opts.params), + }, + body: new URLSearchParams(opts.params).toString(), + }); + if (res.status >= 300 && res.status < 400) + throw new Error(`Customer webhook ${opts.url} attempted a redirect (${res.status})`); + if (!res.ok) throw new Error(`Customer webhook ${opts.url} returned ${res.status}`); + return await res.text(); + } finally { + clearTimeout(timer); + } } diff --git a/test/egress-guard.test.ts b/test/egress-guard.test.ts new file mode 100644 index 0000000..57f6676 --- /dev/null +++ b/test/egress-guard.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { isBlockedAddress, assertPublicUrl, EgressBlockedError } from "../src/twilio/egress-guard.js"; + +describe("isBlockedAddress", () => { + it("blocks IPv4 loopback / private / link-local / CGNAT", () => { + for (const ip of ["127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0"]) + expect(isBlockedAddress(ip)).toBe(true); + }); + it("blocks IPv6 loopback / ULA / link-local / IPv4-mapped", () => { + for (const ip of ["::1", "::", "fc00::1", "fe80::1", "::ffff:169.254.169.254"]) + expect(isBlockedAddress(ip)).toBe(true); + }); + it("allows ordinary public addresses", () => { + for (const ip of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"]) + expect(isBlockedAddress(ip)).toBe(false); + }); +}); + +describe("assertPublicUrl", () => { + const lookup = async (host: string) => + ({ "public.test": ["93.184.216.34"], "evil.test": ["169.254.169.254"] } as Record)[host] ?? []; + + it("returns the URL for a public host", async () => { + const u = await assertPublicUrl("https://public.test/hook", { lookup }); + expect(u.host).toBe("public.test"); + }); + it("throws for a host that resolves to a blocked range", async () => { + await expect(assertPublicUrl("https://evil.test/meta", { lookup })).rejects.toBeInstanceOf(EgressBlockedError); + }); + it("throws for a non-http scheme", async () => { + await expect(assertPublicUrl("file:///etc/passwd", { lookup })).rejects.toBeInstanceOf(EgressBlockedError); + }); + it("allows a blocked host when allowPrivate is set", async () => { + const u = await assertPublicUrl("https://evil.test/meta", { lookup, allowPrivate: true }); + expect(u.host).toBe("evil.test"); + }); + it("rejects a blocked IPv6 literal without needing DNS", async () => { + await expect(assertPublicUrl("https://[::1]/", {})).rejects.toBeInstanceOf(EgressBlockedError); + }); + it("allows a public IPv6 literal without needing DNS", async () => { + const u = await assertPublicUrl("https://[2606:4700:4700::1111]/", {}); + expect(u.host).toBe("[2606:4700:4700::1111]"); + }); + it("wraps a throwing resolver as EgressBlockedError", async () => { + const throwingLookup = async () => { + throw new Error("ENOTFOUND"); + }; + await expect(assertPublicUrl("https://unresolvable.test/", { lookup: throwingLookup })).rejects.toBeInstanceOf( + EgressBlockedError, + ); + }); +}); + +describe("assertPublicUrl host allowlist", () => { + const lookup = async () => ["93.184.216.34"]; + it("allows a host on the allowlist", async () => { + const u = await assertPublicUrl("https://voice.customer.com/hook", { allowHosts: ["voice.customer.com"], lookup }); + expect(u.host).toBe("voice.customer.com"); + }); + it("blocks a public host that is NOT on the allowlist", async () => { + await expect( + assertPublicUrl("https://other.example/hook", { allowHosts: ["voice.customer.com"], lookup }), + ).rejects.toBeInstanceOf(EgressBlockedError); + }); + it("allowlisted host bypasses the range denylist (e.g. localhost for dev)", async () => { + const u = await assertPublicUrl("http://localhost:4000/voice", { allowHosts: ["localhost"] }); + expect(u.hostname).toBe("localhost"); + }); +}); diff --git a/test/egress-post.test.ts b/test/egress-post.test.ts new file mode 100644 index 0000000..8fbfc67 --- /dev/null +++ b/test/egress-post.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { postToCustomer } from "../src/twilio/egress.js"; + +describe("postToCustomer egress guard", () => { + it("refuses to fetch an internal address", async () => { + const fetchImpl = vi.fn(); + await expect( + postToCustomer({ url: "http://169.254.169.254/latest/meta-data/", params: {}, authToken: "t", fetchImpl }), + ).rejects.toThrow(/Blocked egress/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("fetches a public URL with redirects disabled", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch; + await postToCustomer({ + url: "http://public.test/hook", + params: { A: "1" }, + authToken: "t", + fetchImpl, + lookup: async () => ["93.184.216.34"], + }); + const [, init] = (fetchImpl as any).mock.calls[0]; + expect(init.redirect).toBe("manual"); + }); + + it("allows an internal address when allowPrivate is set (local dev)", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch; + await postToCustomer({ + url: "http://127.0.0.1:4000/voice", params: {}, authToken: "t", fetchImpl, allowPrivate: true, + }); + expect(fetchImpl).toHaveBeenCalled(); + }); +}); diff --git a/test/egress.test.ts b/test/egress.test.ts index 9a68851..de8ea10 100644 --- a/test/egress.test.ts +++ b/test/egress.test.ts @@ -58,6 +58,7 @@ describe("postToCustomer", () => { params, authToken: "tok", fetchImpl, + allowPrivate: true, }); expect(body).toContain(""); const [url, init] = (fetchImpl as any).mock.calls[0]; @@ -71,7 +72,13 @@ describe("postToCustomer", () => { it("throws on non-2xx", async () => { const fetchImpl = vi.fn(async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; await expect( - postToCustomer({ url: "https://x.test/voice", params: {}, authToken: "tok", fetchImpl }), + postToCustomer({ + url: "https://x.test/voice", + params: {}, + authToken: "tok", + fetchImpl, + allowPrivate: true, + }), ).rejects.toThrow(/500/); }); }); diff --git a/test/recording-callback.test.ts b/test/recording-callback.test.ts index ad88c65..04720ac 100644 --- a/test/recording-callback.test.ts +++ b/test/recording-callback.test.ts @@ -36,8 +36,12 @@ const config = { authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", + allowPrivateEgress: true, + webhookUser: "u", + webhookPassword: "p", }; const auth = "Basic " + Buffer.from("AC123:tok").toString("base64"); +const webhookAuth = "Basic " + Buffer.from("u:p").toString("base64"); function makeApp() { const bwClient = { @@ -71,6 +75,7 @@ describe("POST /bw/recording-status (recording-available egress)", () => { const res = await app.inject({ method: "POST", url: "/bw/recording-status?cb=" + encodeURIComponent("https://customer.test/rec-ready"), + headers: { authorization: webhookAuth }, payload: { eventType: "recordingAvailable", callId: "c-out-1", @@ -101,6 +106,7 @@ describe("POST /bw/recording-status (recording-available egress)", () => { const res = await app.inject({ method: "POST", url: "/bw/recording-status?cb=" + encodeURIComponent("https://customer.test/rec-ready"), + headers: { authorization: webhookAuth }, payload: { callId: "nope", recordingId: "r-x" }, }); expect(res.statusCode).toBe(204); diff --git a/test/safe-equal.test.ts b/test/safe-equal.test.ts new file mode 100644 index 0000000..7ad684b --- /dev/null +++ b/test/safe-equal.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { safeEqual } from "../src/server/safe-equal.js"; + +describe("safeEqual", () => { + it("returns true for identical strings", () => { + expect(safeEqual("Basic abc123", "Basic abc123")).toBe(true); + }); + it("returns false for same-length differing strings", () => { + expect(safeEqual("Basic abc123", "Basic abc124")).toBe(false); + }); + it("returns false (no throw) for different-length strings", () => { + expect(safeEqual("short", "muchlongervalue")).toBe(false); + }); + it("returns false for empty vs non-empty", () => { + expect(safeEqual("", "x")).toBe(false); + }); +}); diff --git a/test/server-bw-fixture.test.ts b/test/server-bw-fixture.test.ts index 46c8b65..3692678 100644 --- a/test/server-bw-fixture.test.ts +++ b/test/server-bw-fixture.test.ts @@ -23,6 +23,9 @@ describe("inbound path against the real captured BW initiate payload", () => { authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", + allowPrivateEgress: true, + webhookUser: "u", + webhookPassword: "p", }, { fetchImpl, bwClient: { createCall: vi.fn(), modifyCall: vi.fn(), getCall: vi.fn(), listRecordings: vi.fn(), getRecording: vi.fn(), getRecordingMedia: vi.fn(), updateRecording: vi.fn() } }, ); @@ -30,7 +33,10 @@ describe("inbound path against the real captured BW initiate payload", () => { const res = await app.inject({ method: "POST", url: "/bw/initiate", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + authorization: "Basic " + Buffer.from("u:p").toString("base64"), + }, payload: bwFixture.initiateEvent, // full real shape, extra fields included }); diff --git a/test/server-egress.test.ts b/test/server-egress.test.ts new file mode 100644 index 0000000..5e555bc --- /dev/null +++ b/test/server-egress.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi } from "vitest"; +import { buildApp } from "../src/server/app.js"; + +const base = { accountSid: "AC123", authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "http://127.0.0.1:4000/voice", webhookUser: "u", webhookPassword: "p" }; +const bwClient = { createCall: vi.fn(), modifyCall: vi.fn(), getCall: vi.fn(), listRecordings: vi.fn(), getRecording: vi.fn(), getRecordingMedia: vi.fn(), updateRecording: vi.fn() }; +const authHeader = "Basic " + Buffer.from("u:p").toString("base64"); + +it("blocks a loopback voiceUrl by default", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch; + const app = buildApp(base, { fetchImpl, bwClient }); + const res = await app.inject({ method: "POST", url: "/bw/initiate", headers: { authorization: authHeader }, payload: { eventType: "initiate", callId: "c1" } }); + expect(res.statusCode).toBe(502); + expect(fetchImpl).not.toHaveBeenCalled(); +}); + +it("permits a loopback voiceUrl when allowPrivateEgress is set", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch; + const app = buildApp({ ...base, allowPrivateEgress: true }, { fetchImpl, bwClient }); + const res = await app.inject({ method: "POST", url: "/bw/initiate", headers: { authorization: authHeader }, payload: { eventType: "initiate", callId: "c2" } }); + expect(res.statusCode).toBe(200); + expect(fetchImpl).toHaveBeenCalled(); +}); diff --git a/test/server-inbound-auth.test.ts b/test/server-inbound-auth.test.ts new file mode 100644 index 0000000..f385276 --- /dev/null +++ b/test/server-inbound-auth.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from "vitest"; +import { buildApp } from "../src/server/app.js"; + +const config = { + accountSid: "AC123", + authToken: "tok", + publicBaseUrl: "https://adapter.test", + voiceUrl: "https://customer.test/voice", + webhookUser: "bw-user", + webhookPassword: "bw-pass", + // customer.test never resolves via real DNS (RFC 2606), so allow private + // egress to keep this test about auth, not the egress guard. + allowPrivateEgress: true, +}; +function makeApp() { + const fetchImpl = vi.fn( + async () => new Response("", { status: 200 }), + ) as unknown as typeof fetch; + const bwClient = { + createCall: vi.fn(), + modifyCall: vi.fn(), + getCall: vi.fn(), + listRecordings: vi.fn(), + getRecording: vi.fn(), + getRecordingMedia: vi.fn(), + updateRecording: vi.fn(), + }; + return buildApp(config, { fetchImpl, bwClient }); +} +const good = "Basic " + Buffer.from("bw-user:bw-pass").toString("base64"); + +describe("/bw/* inbound auth", () => { + it("401s a request with no credentials", async () => { + const res = await makeApp().inject({ + method: "POST", + url: "/bw/initiate", + payload: { eventType: "initiate", callId: "c1" }, + }); + expect(res.statusCode).toBe(401); + expect(res.headers["www-authenticate"]).toContain("Basic"); + }); + it("401s a request with wrong credentials", async () => { + const res = await makeApp().inject({ + method: "POST", + url: "/bw/initiate", + headers: { authorization: "Basic " + Buffer.from("bw-user:wrong").toString("base64") }, + payload: { eventType: "initiate", callId: "c1" }, + }); + expect(res.statusCode).toBe(401); + }); + it("allows a request with correct credentials", async () => { + const res = await makeApp().inject({ + method: "POST", + url: "/bw/initiate", + headers: { authorization: good }, + payload: { eventType: "initiate", callId: "c1" }, + }); + expect(res.statusCode).toBe(200); + }); + it("does not gate the REST facade with webhook creds", async () => { + const res = await makeApp().inject({ + method: "GET", + url: "/2010-04-01/Accounts/AC123/Calls/CA0.json", + headers: { authorization: good }, + }); + expect(res.statusCode).not.toBe(200); // rejected by REST auth (different secret), not accepted + }); +}); diff --git a/test/server-inbound.test.ts b/test/server-inbound.test.ts index 48f2737..223f6e0 100644 --- a/test/server-inbound.test.ts +++ b/test/server-inbound.test.ts @@ -6,7 +6,11 @@ const config = { authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", + allowPrivateEgress: true, + webhookUser: "u", + webhookPassword: "p", }; +const webhookAuth = "Basic " + Buffer.from("u:p").toString("base64"); function appWithTwiml(twimlByUrl: Record) { const fetchImpl = vi.fn(async (url: any) => { @@ -27,6 +31,7 @@ describe("POST /bw/initiate", () => { const res = await app.inject({ method: "POST", url: "/bw/initiate", + headers: { authorization: webhookAuth }, payload: { eventType: "initiate", callId: "c-1", @@ -50,6 +55,7 @@ describe("POST /bw/initiate", () => { const res = await app.inject({ method: "POST", url: "/bw/initiate", + headers: { authorization: webhookAuth }, payload: { eventType: "initiate", callId: "c-2", from: "+1", to: "+2", direction: "inbound" }, }); expect(res.body).toContain( @@ -64,6 +70,7 @@ describe("POST /bw/initiate", () => { const res = await app.inject({ method: "POST", url: "/bw/initiate", + headers: { authorization: webhookAuth }, payload: { eventType: "initiate", callId: "c-3", from: "+1", to: "+2", direction: "inbound" }, }); expect(res.body).toContain("not yet supported"); @@ -80,11 +87,13 @@ describe("POST /bw/continue", () => { await app.inject({ method: "POST", url: "/bw/initiate", + headers: { authorization: webhookAuth }, payload: { eventType: "initiate", callId: "c-4", from: "+1", to: "+2", direction: "inbound" }, }); const res = await app.inject({ method: "POST", url: `/bw/continue?next=${encodeURIComponent("https://customer.test/menu")}`, + headers: { authorization: webhookAuth }, payload: { eventType: "gather", callId: "c-4", digits: "1" }, }); expect(res.body).toContain("You pressed one"); @@ -99,6 +108,7 @@ describe("POST /bw/disconnect", () => { const res = await app.inject({ method: "POST", url: "/bw/disconnect", + headers: { authorization: webhookAuth }, payload: { eventType: "disconnect", callId: "c-9" }, }); expect(res.statusCode).toBe(204); diff --git a/test/server-numbers.test.ts b/test/server-numbers.test.ts index 3878f81..7a32120 100644 --- a/test/server-numbers.test.ts +++ b/test/server-numbers.test.ts @@ -8,6 +8,8 @@ const config = { publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", numbers: { siteId: "site-1", peerId: "peer-1" }, + webhookUser: "u", + webhookPassword: "p", }; const auth = "Basic " + Buffer.from("AC123:tok").toString("base64"); diff --git a/test/server-rest-get.test.ts b/test/server-rest-get.test.ts index 73388e9..4546a9d 100644 --- a/test/server-rest-get.test.ts +++ b/test/server-rest-get.test.ts @@ -7,6 +7,8 @@ const config = { authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", + webhookUser: "u", + webhookPassword: "p", }; const auth = "Basic " + Buffer.from("AC123:tok").toString("base64"); diff --git a/test/server-rest-modify.test.ts b/test/server-rest-modify.test.ts index 0941c94..4c52fcf 100644 --- a/test/server-rest-modify.test.ts +++ b/test/server-rest-modify.test.ts @@ -7,6 +7,8 @@ const config = { authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", + webhookUser: "u", + webhookPassword: "p", }; const auth = "Basic " + Buffer.from("AC123:tok").toString("base64"); diff --git a/test/server-rest-recordings.test.ts b/test/server-rest-recordings.test.ts index a3e7ce8..215e80d 100644 --- a/test/server-rest-recordings.test.ts +++ b/test/server-rest-recordings.test.ts @@ -8,6 +8,8 @@ const config = { authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", + webhookUser: "u", + webhookPassword: "p", }; const auth = "Basic " + Buffer.from("AC123:tok").toString("base64"); diff --git a/test/server-rest.test.ts b/test/server-rest.test.ts index 88bf210..d34c811 100644 --- a/test/server-rest.test.ts +++ b/test/server-rest.test.ts @@ -7,6 +7,8 @@ const config = { authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", + webhookUser: "u", + webhookPassword: "p", }; describe("POST /2010-04-01/Accounts/:sid/Calls.json", () => { diff --git a/test/server-status-callback.test.ts b/test/server-status-callback.test.ts index 0f05a3b..425bb55 100644 --- a/test/server-status-callback.test.ts +++ b/test/server-status-callback.test.ts @@ -7,8 +7,12 @@ const config = { authToken: "tok", publicBaseUrl: "https://adapter.test", voiceUrl: "https://customer.test/voice", + allowPrivateEgress: true, + webhookUser: "u", + webhookPassword: "p", }; const auth = "Basic " + Buffer.from("AC123:tok").toString("base64"); +const webhookAuth = "Basic " + Buffer.from("u:p").toString("base64"); function makeApp() { const bwClient = { @@ -48,6 +52,7 @@ describe("status callback egress on call completion", () => { const res = await app.inject({ method: "POST", url: "/bw/disconnect", + headers: { authorization: webhookAuth }, payload: { eventType: "disconnect", callId: "c-out-1", @@ -74,6 +79,7 @@ describe("status callback egress on call completion", () => { const res = await app.inject({ method: "POST", url: "/bw/disconnect", + headers: { authorization: webhookAuth }, payload: { eventType: "disconnect", callId: "c-out-1" }, }); @@ -89,6 +95,7 @@ describe("status callback egress on call completion", () => { const res = await app.inject({ method: "POST", url: "/bw/disconnect", + headers: { authorization: webhookAuth }, payload: { eventType: "disconnect", callId: "c-out-1" }, }); diff --git a/test/translate-callback-auth.test.ts b/test/translate-callback-auth.test.ts new file mode 100644 index 0000000..899382a --- /dev/null +++ b/test/translate-callback-auth.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { translateTwiml } from "../src/translator/translate.js"; + +const auth = { username: "bw-user", password: "bw-pass" }; +const rewriteUrl = (u: string) => `https://adapter.test/bw/continue?next=${encodeURIComponent(u)}`; + +describe("callback auth stamping", () => { + it("adds username/password to a Gather that has a rewritten action", () => { + const { bxml } = translateTwiml( + `Hi`, + { rewriteUrl, callbackAuth: auth }, + ); + expect(bxml).toContain(`username="bw-user"`); + expect(bxml).toContain(`password="bw-pass"`); + }); + it("adds username/password to a Redirect", () => { + const { bxml } = translateTwiml(`https://c.test/next`, { rewriteUrl, callbackAuth: auth }); + expect(bxml).toContain(`username="bw-user"`); + }); + it("omits credentials when callbackAuth is not provided", () => { + const { bxml } = translateTwiml(`https://c.test/next`, { rewriteUrl }); + expect(bxml).not.toContain("username="); + }); +});