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
60 changes: 60 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Contributing

First off — thanks for being here. If you're reading this, you're either using the adapter or thinking about improving it, and either way we're glad you showed up.

Before anything else, one honest note about what this project is and isn't.

## The deal (please read this first)

**bw-voice-adapter is a best-effort, community-supported project — not a fully supported Bandwidth product.**

There's no team staffed on this full-time. It was built to solve a real problem well, and it's proven on real calls, but nobody is on the hook for a support SLA, a release schedule, or a guaranteed response time. We want to be upfront about that so your expectations and ours line up from day one.

What that means in practice:

- **Open an issue or put up a PR — genuinely, please do.** That's exactly how this project stays useful. A Bandmate will take a look when they're able to.
- **Response times will vary.** Could be a day, could be longer, depending on what else is on people's plates. It's not indifference — it's just that this isn't anyone's dedicated job.
- **No promises on prioritization.** We can't commit to fixing a given bug or shipping a given feature on any particular timeline. If something is blocking you, the fastest path is often a PR you drive yourself, with us helping review.

If you need guaranteed, SLA-backed support for a production voice migration, that's a conversation to have with your Bandwidth account team — not this repo.

None of this is meant to wave you off. Best-effort still means real effort. We'd just rather tell you the truth than let you find it out the hard way.

## How to report a problem

Open a GitHub issue with enough for someone to reproduce it without a back-and-forth:

- **What you were trying to do** — the TwiML/verb or REST call involved.
- **What happened vs. what you expected** — include the adapter's output. When it can't do something, it's designed to say so and stop rather than fail silently, so paste that message if you got one.
- **A minimal repro** — the smallest TwiML snippet or request that triggers it. Scrub any real numbers, tokens, or customer data first.
- **Environment** — adapter version/commit, Node version, and whether you're hitting real Bandwidth or a test setup.

> ⚠️ **Never** paste live credentials, real customer phone numbers, recordings, or any PII into an issue or PR. Redact before you post.

## How to contribute a change

1. **Fork and branch** off `main`.
2. **Match the existing style.** The translation logic is a fixed rulebook driven by [`src/matrix/twilio-voice.json`](src/matrix/twilio-voice.json), not ad-hoc guessing. If you're adding or changing verb/attribute support, that matrix is usually where it starts — keep the "translate faithfully or fail loudly" principle intact.
3. **Add tests.** This repo leans on its test suite hard (that's how we trust it on real calls). New behavior needs coverage; changed behavior needs its tests updated.
4. **Keep the checks green** before you open the PR:

```bash
npm install
npm test # vitest suite
npm run typecheck # or: npx tsc --noEmit
```

5. **Write a clear PR description** — what changed, why, and how you verified it. If you tested against real calls, say so; that's the highest-value signal we have.

## What makes a change easy to accept

- It keeps the adapter **honest**: correct translations, and a loud, clear failure when something isn't supported — never a silently broken call.
- It's **tested** and the suite passes.
- It's **scoped** — one focused change per PR beats a sprawling one.
- It updates the **docs** (README capability tables especially) when it changes what the adapter can do.

## A realistic word on review

Because this is best-effort, review may take a while, and a maintainer might ask you to carry a change further than you expected — more tests, a doc update, a tweak to the matrix. It's not us being precious; it's the bar that keeps this thing trustworthy on live calls. Thanks in advance for your patience with that.

Seriously — thank you for contributing. Every good issue and every solid PR makes this more useful for the next person trying to move a voice app without rewriting it.
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);
}
Loading