Skip to content

[Bug] validateRequest() rejects valid signatures containing apostrophes (regression v5.0.4) #1183

Description

@brypo

Overview of the Issue

validateRequest() silently returns false for webhook requests that were validly signed by Twilio, when the URL contains characters that the WHATWG new URL() parser percent-encodes during normalization.

The confirmed case is an apostrophe ('%27), but any character that new URL() encodes and Twilio’s backend does not should produce the same failure.

No error is thrown. The request is rejected as if the signature were invalid.

Motivation / Use Case

A customer can implement validateRequest() exactly as documented and still have webhook validation fail in production when a query parameter contains an apostrophe or another affected character.

Twilio successfully delivers the webhook and signs the original URL. On the customer side, validateRequest() returns false, so the request is rejected as invalid.

twilio-node Version(s)

Confirmed regression introduced in v5.0.4 and continues through to the latest version. Working correctly in v5.0.3 and earlier.

In v5.0.3, validateRequest() used url-parse for port normalization. url-parse did not re-serialize query characters, so the URL string passed into HMAC matched what Twilio signed.

In v5.0.4, url-parse was replaced with the built-in new URL(). Unlike url-parse, new URL() normalizes the full URL during parsing, including percent-encoding characters such as ' to %27. Twilio’s backend did not encode those characters in the signed URL, so the HMAC input changes and validation fails.

See: v5.0.4 changelog

Reproduce the Error

const twilio = require('twilio');

const authToken = '<your-auth-token>';
const url = "https://example.twil.io/webhook?name=William+O'hara";

const incomingSignature = twilio.getExpectedTwilioSignature(authToken, url, {});

// getExpectedTwilioSignature correctly matches 
console.log(twilio.getExpectedTwilioSignature(authToken, url, {}));

// validateRequest rejects the same inputs 
console.log(twilio.validateRequest(authToken, incomingSignature, url, {}));
// → false (should be true)

Two SDK functions given the same inputs produce different results.

Root Cause

In lib/webhooks/webhooks.js, validateRequest() parses the URL with new URL(url). Both urlWithPort and urlWithoutPort are derived from that normalized object, so the HMAC is computed from a modified URL string rather than the original string Twilio signed.

const urlObject = new URL(url);          // ' becomes %27 here
const urlWithPort = addPort(urlObject);    // derived from normalized object
const urlWithoutPort = removePort(urlObject); // derived from normalized object

getExpectedTwilioSignature() hashes the original URL string as-is. That is why it correctly reproduces the same signature that validateRequest() rejects.

Related Issues

No prior issues found for this exact behavior. (Sorry if I missed it.)

Suggest a Fix

One option is to use new URL() only to extract protocol and hostname, then build the port-normalized variants from the original URL string so the query string is never re-encoded:

const urlObject = new URL(url);
const origin = url.match(/^(https?:\/\/[^/?#]*)/)?.[1] ?? "";
const rest = url.slice(origin.length);
const defaultPort = urlObject.protocol === "https:" ? "443" : "80";
const hostNoPort = urlObject.hostname;
const urlWithPort = `${urlObject.protocol}//${hostNoPort}:${defaultPort}${rest}`;
const urlWithoutPort = `${urlObject.protocol}//${hostNoPort}${rest}`;

Another option is to reintroduce url-parse, which handled port normalization without altering query-string serialization.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions