[Bug] validateRequest() rejects valid signatures containing apostrophes (regression v5.0.4)
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
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
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from twilio/twilio-node
-
Regression: Bulk Exports specific-day `fetch()` returns `void` instead of `DayInstance` in 6.1.0 Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
twilio/twilio-node#1207 · 1 reaction ·
-
Difficulty 1/5 Under an hour Newbie friendliness 72/100
twilio/twilio-node#1192 ·
-
Difficulty 4/5 3-5 days Newbie friendliness 55/100
twilio/twilio-node#1210 ·
-
Authy/Twillo Open
Difficulty 5/5 Over a week Newbie friendliness 10/100
twilio/twilio-node#1208 ·
-
Difficulty 4/5 3-5 days Newbie friendliness 52/100
twilio/twilio-node#1190 · 1 comment ·
All issues in twilio/twilio-node
Similar issues
-
calcite-components needs triage refactor
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Esri/calcite-design-system#15203 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 91/100
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficulty 1/5 Under an hour Newbie friendliness 95/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Automattic/studio#4908 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 90/100