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

Open Beginner friendly
#1,183 2 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
68/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
javascript, node.js
Domain
api, backend, security

Research direction

Start in lib/webhooks/webhooks.js and compare how validateRequest() derives its URL variants with how getExpectedTwilioSignature() uses the original URL. Reproduce the apostrophe case, then add a regression test showing both functions agree and run the webhook tests. Done means valid signatures remain valid without query-string re-encoding.

Written by the indexing model from the issue text.

Description

Stale

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.

Dominant language
TypeScript
Stars
1.5k
Forks
564
Avg merge
14h 2m
Merged PRs (30d)
3

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from twilio/twilio-node

All issues in twilio/twilio-node

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.