[engage-messaging-twilio] Validate twilioHostname against Twilio domain (SECOPS-25241) - #3907
[engage-messaging-twilio] Validate twilioHostname against Twilio domain (SECOPS-25241)#3907harsh-joshi99 wants to merge 2 commits into
Conversation
…in (SECOPS-25241) The customer-editable `twilioHostname` setting was interpolated, unvalidated, into request URLs carrying the Twilio API key via Basic auth. Setting it to a non-Twilio host caused the credentials to be forwarded to that host (HackerOne AD-003 / STRATCONN-6910). Add `validateTwilioHostname` allowlisting hosts under `*.twilio.com` (case-insensitive) and throw a PayloadValidationError before any credentialed request is made. Applied to all three call sites: SMS/WhatsApp sends, mobile push sends, and testAuthentication. Tests updated to use valid Twilio subdomains and add rejection tests asserting no request is made for a non-Twilio host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
This PR mitigates a credential-forwarding vulnerability in the Engage Messaging Twilio destination by validating the customer-configurable twilioHostname against an allowlist of *.twilio.com before any credentialed requests are sent.
Changes:
- Added
validateTwilioHostname()backed by a Twilio-domain allowlist regex. - Applied hostname validation at all credentialed request call sites (SMS/WhatsApp sender, mobile push sender, and
testAuthentication). - Updated existing “custom hostname” tests to use valid Twilio subdomains and added rejection tests for non-Twilio hostnames.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/destination-actions/src/destinations/engage-messaging-twilio/utils/TwilioMessageSender.ts | Introduces Twilio hostname allowlist validation and reusable helper. |
| packages/destination-actions/src/destinations/engage-messaging-twilio/utils/PhoneMessageSender.ts | Ensures SMS/WhatsApp hostname is validated before being used in requests. |
| packages/destination-actions/src/destinations/engage-messaging-twilio/sendMobilePush/PushSender.ts | Ensures mobile push hostname is validated before being used in requests. |
| packages/destination-actions/src/destinations/engage-messaging-twilio/index.ts | Validates hostname during testAuthentication to prevent credential forwarding. |
| packages/destination-actions/src/destinations/engage-messaging-twilio/tests/send-whatsapp.test.ts | Updates custom-host test data and adds rejection coverage for non-Twilio hostnames. |
| packages/destination-actions/src/destinations/engage-messaging-twilio/tests/send-sms.test.ts | Updates custom-host test data and adds rejection coverage for non-Twilio hostnames. |
| packages/destination-actions/src/destinations/engage-messaging-twilio/tests/send-mobile-push.test.ts | Updates custom-host test data and adds rejection coverage for non-Twilio hostnames. |
| throw new PayloadValidationError( | ||
| `Invalid Twilio hostname: "${hostname}". Hostname must be within the twilio.com domain.` | ||
| ) |
harsh-joshi99
left a comment
There was a problem hiding this comment.
Note
Self-Review
Posted by PR author (harsh-joshi99) — self-reviews cannot apply the APPROVED label. Intended verdict: Manual Review Needed plugin-review for the APPROVED label.
Code Review Personae Verdict: Manual Review Needed ⚠️
Three independent bug-finding passes and a policy scan found no critical, high, or medium issues. The credential-forwarding fix is complete: all three credentialed call sites route through the validated hostname getter, the regex resists the enumerated bypass vectors (userinfo/path/port/newline/suffix/uppercase), and the tests genuinely prove the security property (nock.disableNetConnect makes 'no interceptor' a hard guarantee).
One low-severity consistency nit: empty-string twilioHostname is handled differently across the three sites.
📝 Deep Code Review (3 Passes) — 0 Critical, 0 High, 0 Medium, 1 Low 🔵
Three independent bug-finder passes converged on the same conclusion: the fix is correct and not bypassable.
Consensus verifications (agreed by multiple passes):
- Coverage complete — the only two request sites interpolating the customer-controlled
twilioHostname(PhoneMessageSender.ts:64SMS/WhatsApp,PushSender.ts:90push) both route through thevalidateTwilioHostname-guarded getter;testAuthentication(index.ts:171) validates inline.content.twilio.com(TwilioMessageSender.ts:93) and the profile-API host are hardcoded/framework-derived and correctly need no guarding. WhatsApp inherits the validated getter from PhoneMessageSender. grep confirmed no othersettings.twilioHostnameread bypasses the getter. - Regex robust —
/^([a-z0-9-]+\.)+twilio\.com$/on.toLowerCase(), empirically tested in node by 2 passes: rejects userinfo (api.twilio.com@evil.com), port, path, suffix (api.twilio.com.evil.com), prefix (eviltwilio.com), baretwilio.com, and all newline-injection cases (JS$withoutmflag anchors to absolute end-of-string, soapi.twilio.com\n@evil.comis rejected). - No NPE — all three call sites pass a guaranteed string (
?? DEFAULT/?.length ? : DEFAULT) to.toLowerCase(). - Throwing getter is safe —
PayloadValidationError(non-retryable, correct class for a config error) is thrown only lazily inside the async perform lifecycle; not read in constructors/beforePerform/stats, so it is not swallowed and does not crash the process. - No secret leakage —
beforePerformlogs onlycontentSidandtwilioApiKeySID(the SID, not the secret); the error echoes the customer hostname, never the key. - Tests prove the property —
nock.disableNetConnect()(setup-after-env) makes any attempted external send throwNetConnectNotAllowed(which does not match/Invalid Twilio hostname/), so a regressed send fails the test loudly rather than passing silently.
Findings
- 🔵 LOW: Empty-string twilioHostname handled inconsistently across the three sites (
packages/destination-actions/src/destinations/engage-messaging-twilio/sendMobilePush/PushSender.ts:35)
[Found by 2/3 passes] PushSender usestwilioHostname?.length ? twilioHostname : DEFAULT(empty string falls back to the default and succeeds), while PhoneMessageSender (line 17) and index.ts testAuthentication usetwilioHostname ?? DEFAULT(empty string is not nullish, so''reaches validateTwilioHostname and throwsInvalid Twilio hostname: ""). Not a new crash (pre-change an empty host produced a malformedhttps:///...URL that also failed) and not exploitable (an empty host cannot exfiltrate credentials), but the divergence is a latent inconsistency. Decision on this PR: left as-is to keep the security patch minimal.
Suggestion: If normalizing later: resolve the host uniformly across all three sites, e.g.validateTwilioHostname(settings.twilioHostname?.length ? settings.twilioHostname : DEFAULT), so empty/whitespace consistently falls back to the safe default.
🛡️ Policy Compliance Review — No violations (3 policies evaluated)
Evaluated 3 policies (POL-001-port-forward-to-prod-db, POL-002-prod-db-write-from-laptop, POL-003-credentials-in-pr) across all review surfaces (PR body, commit messages, per-file code surfaces). No violations detected.
Generated by code-review-personae v0.8.15 | claude-opus-4-8 | deep code review (3 passes), policy review
| return this.settings.twilioHostname?.length ? this.settings.twilioHostname : this.DEFAULT_HOSTNAME | ||
| return validateTwilioHostname( | ||
| this.settings.twilioHostname?.length ? this.settings.twilioHostname : this.DEFAULT_HOSTNAME | ||
| ) |
There was a problem hiding this comment.
Note
🔵 LOW: Empty-string twilioHostname handled inconsistently across the three sites
[Found by 2/3 passes] PushSender uses twilioHostname?.length ? twilioHostname : DEFAULT (empty string falls back to the default and succeeds), while PhoneMessageSender (line 17) and index.ts testAuthentication use twilioHostname ?? DEFAULT (empty string is not nullish, so '' reaches validateTwilioHostname and throws Invalid Twilio hostname: ""). Not a new crash (pre-change an empty host produced a malformed https:///... URL that also failed) and not exploitable (an empty host cannot exfiltrate credentials), but the divergence is a latent inconsistency. Decision on this PR: left as-is to keep the security patch minimal.
Suggestion: If normalizing later: resolve the host uniformly across all three sites, e.g. validateTwilioHostname(settings.twilioHostname?.length ? settings.twilioHostname : DEFAULT), so empty/whitespace consistently falls back to the safe default.
Summary
Fixes a credential-forwarding vulnerability (HackerOne AD-003 / SECOPS-25241 / STRATCONN-6910) in the Engage Messaging Twilio destination.
The customer-editable
twilioHostnamesetting was interpolated, unvalidated, into request URLs that carry the Twilio API key viaAuthorization: Basic base64(twilioApiKeySID:twilioApiKeySecret). Setting it to a non-Twilio host causes Segment to forward the account's Twilio API key to that host.Fix
Adds
validateTwilioHostname()which allowlists hostnames under*.twilio.com(case-insensitive) and throws aPayloadValidationErrorbefore any credentialed request is made. The character class also blocks userinfo/path bypasses such asapi.twilio.com@attacker.com.Applied to all three credentialed call sites:
utils/PhoneMessageSender.ts— SMS + WhatsApp (via inheritance)sendMobilePush/PushSender.ts— mobile pushindex.ts—testAuthenticationThis matches the researcher's stated control: "The production Twilio API host should remain api.twilio.com or a validated Twilio-owned API host." All hosts this destination talks to today (
api.twilio.com,content.twilio.com,push.ashburn.us1.twilio.com, regionalapi.<region>.twilio.com) pass the allowlist.Testing
PayloadValidationErroris thrown and no outbound request is made for a non-Twilio host.End-to-end verification with the researcher's PoC
Ran the researcher's PoC harness (
ad_003_engage_messaging_twilio_hostname_credential_forwarding) against the real Segment Actions runtime on bothmainand this branch. It usesnock.disableNetConnect()and synthetic credentials — no real Twilio/network access — settingtwilioHostnameto the attacker hosttwilio-credential-capture.exampleand invokingsendSms.On
main(unpatched) — credential leaked to attacker host:{ "success": true, "finding": "AD-003", "mode": "real-segment-actions-runtime", "primary_proof": "artifacts/control_comparison.txt", "captured_request": "https://twilio-credential-capture.example/2010-04-01/Accounts/AC00000000000000000000000000000000/Messages.json", "decoded_credential_material": "SK_ATTACKER_VISIBLE_KEY_SID:TWILIO_API_KEY_SECRET" }On this branch (fixed) — blocked before any request, no credential sent:
{ "success": false, "finding": "AD-003", "error": "Invalid Twilio hostname: \"twilio-credential-capture.example\". Hostname must be within the twilio.com domain." }Rollout note
This enforces immediately (throws). The only regression vector is a live config using a non-
twilio.comhost that works today; every host in the current code path is undertwilio.com. Flagging for reviewer awareness in case a measure-first / feature-flagged rollout is preferred for this high-volume destination.🤖 Generated with Claude Code