Skip to content

Commit

Permalink
fix(webhooks): validate Twilio signatures with escaped and unescaped …
Browse files Browse the repository at this point in the history
…query string values fixes twilio#1059
  • Loading branch information
leon19 committed Jan 9, 2025
1 parent 419b33c commit eb7ba24
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 15 deletions.
105 changes: 105 additions & 0 deletions spec/unit/webhooks/webhooks.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { getExpectedTwilioSignature, validateRequest } from '../../../src';

describe('webhooks', () => {
const authToken = 's3cr3t';

describe('validateRequest()', () => {
it('should return false when the signature URL does not match the target URL', () => {
const serverUrl = 'https://example.com/path?test=param';
const targetUrl = 'https://example.com/path?test=param2';

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(false);
});

describe('when the signature is derived from an URL with port', () => {
it('should return true when the target url contains the port', () => {
const serverUrl = 'https://example.com:443/path?test=param';
const targetUrl = 'https://example.com:443/path?test=param';

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(true);
});

it('should return true when the target url does not contain the port', () => {
const serverUrl = 'https://example.com:443/path?test=param';
const targetUrl = 'https://example.com/path?test=param';

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(true);
});
});

describe('when the signature is derived from an URL without port', () => {
it('should return true when the target url does not contain the port', () => {
const serverUrl = 'https://example.com/path?test=param';
const targetUrl = 'https://example.com/path?test=param';

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(true);
});

it('should return true when the target url contains the port', () => {
const serverUrl = 'https://example.com/path?test=param';
const targetUrl = 'https://example.com:443/path?test=param';

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(true);
});
});

describe('when the signature is derived from an URL with a query param containing an unescaped single quote', () => {
it('should return true when the target url contains the unescaped single quote', () => {
const serverUrl = "https://example.com/path?test=param'WithQuote";
const targetUrl = "https://example.com/path?test=param'WithQuote";

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(true);
});

it('should return true when the target url contains the escaped single quote', () => {
const serverUrl = "https://example.com/path?test=param'WithQuote";
const targetUrl = 'https://example.com/path?test=param%27WithQuote';

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(true);
});
});

describe('when the signature is derived from an URL with a query param containing an escaped single quote', () => {
it('should return true when the target url contains the unescaped single quote', () => {
const serverUrl = 'https://example.com/path?test=param%27WithQuote';
const targetUrl = "https://example.com/path?test=param'WithQuote";

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(true);
});

it('should return true when the target url contains the escaped single quote', () => {
const serverUrl = 'https://example.com/path?test=param%27WithQuote';
const targetUrl = 'https://example.com/path?test=param%27WithQuote';

const signature = getExpectedTwilioSignature(authToken, serverUrl, {});
const result = validateRequest(authToken, signature, targetUrl, {});

expect(result).toBe(true);
});
});
});
});
45 changes: 30 additions & 15 deletions src/webhooks/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const scmp = require("scmp");
import crypto from "crypto";
import urllib from "url";
import { IncomingHttpHeaders } from "http2";
import { parse, stringify } from 'querystring'

export interface Request {
protocol: string;
Expand Down Expand Up @@ -102,6 +103,18 @@ function removePort(parsedUrl: URL): string {
return parsedUrl.toString();
}

function withLegacyQuerystring(url: string): string {
const parsedUrl = new URL(url);

if (parsedUrl.search) {
const qs = parse(parsedUrl.search.slice(1))
parsedUrl.search = ""
return parsedUrl.toString() + "?" + stringify(qs);
}

return url
}

/**
Utility function to convert request parameter to a string format
Expand Down Expand Up @@ -179,35 +192,37 @@ export function validateRequest(
): boolean {
twilioHeader = twilioHeader || "";
const urlObject = new URL(url);
const urlWithPort = addPort(urlObject);
const urlWithoutPort = removePort(urlObject);

/*
* Check signature of the url with and without the port number
* and with and without the legacy querystring (special chars are encoded when using `new URL()`)
* since signature generation on the back end is inconsistent
*/
const signatureWithPort = getExpectedTwilioSignature(
authToken,
urlWithPort,
params
);
return validateSignatureWithUrl(authToken, twilioHeader, removePort(urlObject), params)
|| validateSignatureWithUrl(authToken, twilioHeader, addPort(urlObject), params)
|| validateSignatureWithUrl(authToken, twilioHeader, withLegacyQuerystring(removePort(urlObject)), params)
|| validateSignatureWithUrl(authToken, twilioHeader, withLegacyQuerystring(addPort(urlObject)), params)
}

function validateSignatureWithUrl(
authToken: string,
twilioHeader: string,
url: string,
params: Record<string, any>
) {
const signatureWithoutPort = getExpectedTwilioSignature(
authToken,
urlWithoutPort,
url,
params
);
const validSignatureWithPort = scmp(
Buffer.from(twilioHeader),
Buffer.from(signatureWithPort)
);
const validSignatureWithoutPort = scmp(

return scmp(
Buffer.from(twilioHeader),
Buffer.from(signatureWithoutPort)
);

return validSignatureWithoutPort || validSignatureWithPort;
}


export function validateBody(
body: string,
bodyHash: any[] | string | Buffer
Expand Down

0 comments on commit eb7ba24

Please sign in to comment.