Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion scripts/bench-adapter-overhead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
Expand Down Expand Up @@ -87,7 +91,10 @@ async function timeAdapter(doc: string, i: number): Promise<number> {
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",
Expand Down
90 changes: 54 additions & 36 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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<string, string>;
const { To, From, Url } = body;
// Validation order matches live Twilio: To, then Url, then From.
Expand Down Expand Up @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand Down
7 changes: 6 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") } } : {}),
},
{
Expand All @@ -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}`));
11 changes: 11 additions & 0 deletions src/server/safe-equal.ts
Original file line number Diff line number Diff line change
@@ -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);
}
33 changes: 33 additions & 0 deletions src/translator/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = [];
Expand All @@ -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,
Expand Down
Loading