diff --git a/fern/products/swml/pages/guides/basics/swml_remote_server.mdx b/fern/products/swml/pages/guides/basics/swml_remote_server.mdx index f7c42d6c73..366a1ee386 100644 --- a/fern/products/swml/pages/guides/basics/swml_remote_server.mdx +++ b/fern/products/swml/pages/guides/basics/swml_remote_server.mdx @@ -188,6 +188,18 @@ app.post("/start", async (req, res) => { app.listen(3000); ``` +### Verifying that the request came from SignalWire + +Anyone who learns your endpoint URL can POST to it and read back the SWML document you return, so a +public endpoint should check who is calling it before it answers. + +SignalWire signs every request for a SWML document with an HMAC signature in the +`X-Signalwire-Signature` header, which you can verify against your project's signing key. + + + How the signature is computed, and how to verify it in Node, Python, or Ruby. + + ## Conclusion We have shown how to handle incoming calls from code, by emitting SWML instructions that say something on a call, but it can do so much more! For more advanced applications, you'll want to check out [SWML's Technical Reference](/docs/swml). diff --git a/fern/products/swml/pages/guides/basics/webhook-security.mdx b/fern/products/swml/pages/guides/basics/webhook-security.mdx new file mode 100644 index 0000000000..04976e3b3d --- /dev/null +++ b/fern/products/swml/pages/guides/basics/webhook-security.mdx @@ -0,0 +1,274 @@ +--- +title: Verify SWML request signatures +subtitle: Confirm that a request for a SWML document really came from SignalWire. +slug: /guides/webhook-security +description: Verify the HMAC signature SignalWire sends with every request for a SWML document, so your server can reject forged requests. +max-toc-depth: 3 +--- + +When you serve SWML from your own web server, anyone who learns your endpoint URL can POST to it +and read back the SWML document you return. Since a SWML document can contain phone numbers, SIP +credentials, and prompts, that endpoint should not answer to just anyone. + +To let you check the caller, SignalWire signs every request for a SWML document with an HMAC +signature derived from your project's signing key. Verifying that signature proves the request came +from SignalWire and that neither the URL nor the body was altered in transit. + + +For production applications it is extremely important to verify the signature, so that requests +from a malicious third party are rejected instead of being served a SWML document. + + +## Which requests are signed + +Every POST SignalWire makes to fetch a SWML document from a URL you control is signed. That +includes: + +- The initial fetch, when a Resource or phone number is configured with an **External URL** rather + than a hosted script, and the fetch from your fallback URL when the primary one fails. +- Every subsequent fetch caused by [`execute`](/docs/swml/reference/calling/execute) or + [`transfer`](/docs/swml/reference/calling/transfer) pointing at an external URL. +- The same fetches on the messaging side, when a number handles inbound SMS and MMS with + [Messaging SWML](/docs/swml/reference/messaging). + +Requests during a call carry two headers. Requests for a messaging document carry the SHA-1 header +alone, so verify that one if your endpoint serves both. + +| Header | Algorithm | Sent on | +| :--- | :--- | :--- | +| `X-Signalwire-Signature` | HMAC-SHA1, hex encoded | Every signed request | +| `X-Signalwire-SHA256-Signature` | HMAC-SHA256, hex encoded | Call requests | + +Both are computed over the same string: the request URL concatenated directly with the raw request +body, with no separator. + +```text +signature = hex( HMAC( signing_key, url + raw_body ) ) +``` + +The `url` is the full URL SignalWire requested, including any query string. If you embedded basic +auth credentials in the URL, they are stripped before signing. The `raw_body` is the JSON payload +exactly as sent — the object containing `call` (or `message` for a messaging document), `vars`, +`envs`, and, when the document was reached through `execute` or `transfer`, `params`. + + +Verify against the URL you configured in the Dashboard, not the URL your framework reconstructs +from the incoming request. Proxies, load balancers, and tunnels such as ngrok routinely rewrite the +host or scheme, which changes the string being hashed and makes a valid signature look invalid. + + +## Which requests are not signed + +Signatures cover requests for a SWML document. They do not cover everything a document can send to +your server, so an endpoint that insists on a signature will reject traffic you meant to accept. + +The POST to a SWAIG function's `web_hook_url` is unsigned. Protect it with HTTP basic auth: embed +the credentials in the URL as `username:password@url`, or set `web_hook_auth_user` and +`web_hook_auth_password` in [`SWAIG.defaults`](/docs/swml/reference/calling/ai/swaig) or on the +individual [SWAIG function](/docs/swml/reference/calling/ai/swaig/functions). Agents built with the +Server SDKs authenticate differently again — basic auth on every request, plus per-function tokens +for sensitive operations, covered in [Server SDK security](/docs/server-sdks/guides/security). + +The [`request`](/docs/swml/reference/calling/request) method is unsigned as well when a call runs +it. Use basic auth in its URL, or send a shared secret in a custom `X-` header through the method's +`headers` parameter. Its [messaging counterpart](/docs/swml/reference/messaging/request) does carry +the SHA-1 header. + +## Get your signing key + +Your signing key is on the [API Credentials](https://my.signalwire.com?page=credentials) page of +your Dashboard. Click **Show** to reveal it. Each project has its own key, so use the key belonging +to the project that serves the call. + + + +![The API Credentials page in a SignalWire Space showing the signing key](/assets/images/dashboard/credentials/api-credentials-with-signing-key.webp) + + + +You can rotate the key with the reset button on the same page. A new key takes about a minute to +become active, and the page shows it to you before you confirm the reset so you can copy it into +your application first. + +Treat the signing key like a password: keep it in an environment variable or a secret manager, not +in the source you deploy. + +## Verify the signature in Node + +The `validateRequest` helper in `@signalwire/web-api` implements the check for you: + +```bash +npm install @signalwire/web-api +``` + +`validateRequest` needs the **raw** request body, so capture it before your JSON parser consumes +it. Re-serializing the parsed object is not reliable — key order and whitespace change, and the +hash changes with them. + +```javascript title="index.js" +const express = require("express"); +const { validateRequest } = require("@signalwire/web-api"); + +const app = express(); + +// Keep the raw body around for signature verification. +app.use( + express.json({ + verify: (req, _res, buf) => { + req.rawBody = buf.toString(); + }, + }) +); + +// The public-facing URL you configured in the Dashboard. +const WEBHOOK_URL = "https://example.ngrok.io/start"; + +app.post("/start", (req, res) => { + const valid = validateRequest( + process.env.SIGNALWIRE_SIGNING_KEY, + req.headers["x-signalwire-signature"], + WEBHOOK_URL, + req.rawBody + ); + + if (!valid) { + return res.status(403).send("Invalid signature"); + } + + res.send(` + sections: + main: + - play: + url: 'say:Hello from SignalWire!' + `); +}); + +app.listen(3000); +``` + +## Verify the signature in any language + +The scheme is a plain hex HMAC, so you can implement it directly wherever a helper is not +available. Compare digests with a constant-time comparison rather than string equality. + + + +```python +import hmac +import hashlib +import os + +from flask import Flask, request, Response + +app = Flask(__name__) + +# The public-facing URL you configured in the Dashboard. +WEBHOOK_URL = "https://example.ngrok.io/start" + + +def signature_is_valid(url, raw_body, header): + expected = hmac.new( + os.environ["SIGNALWIRE_SIGNING_KEY"].encode(), + (url + raw_body).encode(), + hashlib.sha1, # hashlib.sha256 for X-Signalwire-SHA256-Signature + ).hexdigest() + + return hmac.compare_digest(expected, header or "") + + +@app.route("/start", methods=["POST"]) +def start(): + raw_body = request.get_data(as_text=True) + header = request.headers.get("X-Signalwire-Signature") + + if not signature_is_valid(WEBHOOK_URL, raw_body, header): + return Response("Invalid signature", status=403) + + return Response( + """ + sections: + main: + - play: + url: 'say:Hello from SignalWire!' + """, + mimetype="text/plain", + ) +``` + + +```ruby +require "openssl" +require "sinatra" + +# The public-facing URL you configured in the Dashboard. +WEBHOOK_URL = "https://example.ngrok.io/start" + +def signature_is_valid?(url, raw_body, header) + expected = OpenSSL::HMAC.hexdigest( + "SHA1", # "SHA256" for X-Signalwire-SHA256-Signature + ENV.fetch("SIGNALWIRE_SIGNING_KEY"), + url + raw_body + ) + + OpenSSL.secure_compare(expected, header.to_s) +end + +post "/start" do + raw_body = request.body.read + + unless signature_is_valid?(WEBHOOK_URL, raw_body, env["HTTP_X_SIGNALWIRE_SIGNATURE"]) + halt 403, "Invalid signature" + end + + <<~SWML + sections: + main: + - play: + url: 'say:Hello from SignalWire!' + SWML +end +``` + + + +To verify the stronger header on a call request, hash the same `url + raw_body` string with SHA-256 +and compare it against `X-Signalwire-SHA256-Signature`. + +## Confirm your endpoint rejects forgeries + +With the server running and reachable at the URL you configured, POST to it yourself, without a +signature: + +```bash +curl -i -X POST https://example.ngrok.io/start \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +The response is `403 Forbidden`, with `Invalid signature` as the body. Now place a call to the +number pointed at that URL: this request carries a valid signature, passes the check, and the caller +hears the prompt your document plays. + +If the real call is rejected too, the `WEBHOOK_URL` in your code doesn't match the URL SignalWire +requested — an ngrok URL that changed when the tunnel restarted is the common cause. The next +section covers the rest. + +## Troubleshoot a failing signature + +A signature that never validates almost always comes down to one of these: + +- **The URL does not match.** Scheme, host, port, path, and query string all feed the hash. Use the + exact URL configured in the Dashboard, including the query string if you configured one. +- **The body was re-serialized.** Hash the bytes you received, not `JSON.stringify` of the parsed + object. +- **The wrong project's key.** Signing keys are per project. +- **The key was just rotated.** A new key needs about a minute to become active. +- **Basic auth in the URL.** Credentials embedded in the URL are stripped before signing, so hash + the URL without them. + +## Next steps + +- **[Handle incoming calls from code](/docs/swml/guides/remote-server)** — set up the external SWML + endpoint this guide protects. +- **[Webhooks](/docs/platform/webhooks)** — how webhooks and status callbacks work across the + platform.