From 33cfc046e0285271acab701bfee7828e99addcc8 Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Tue, 22 Sep 2026 09:12:34 +0200 Subject: [PATCH] fix: handle the MFA sms link on the callback the challenge actually uses The challenge opens its `sms:` link with `target="_blank"`. On Android `setSupportMultipleWindows` is always on in react-native-webview, so that navigation goes to `WebChromeClient.onCreateWindow` and never reaches `onShouldStartLoadWithRequest`. Measured on a Pixel 7: with no `onOpenWindow` prop set, no JS callback fires at all and the URL is handed to the system, which shows an app chooser listing WhatsApp - the app the challenge tells users not to use. The same applies to the `https://www.hcaptcha.com` policy links, which left the app the same way. Route both callbacks through one handler and add `onOpenWindow`, which recovers every `target="_blank"` case in JS. Parse the link rather than passing it through. The recipient can carry formatting a messaging app rejects, and the challenge emits an empty leading query parameter (`?&body=`). Splitting on `?` before `;` keeps the body of an RFC 5724 link with subscriber params, and percent-decoding is done without form semantics so a literal `+` survives. The body is re-encoded unchanged so the one-time code reaches the composer byte-exact. The rejection message from `Linking.openURL` embeds the URL, and the body carries the one-time code, so it is no longer forwarded to `onMessage`. Co-Authored-By: Claude Opus 5 (1M context) --- Hcaptcha.js | 72 +++++-- __tests__/Hcaptcha.test.js | 43 ++++- .../ConfirmHcaptcha.test.js.snap | 1 + __tests__/__snapshots__/Hcaptcha.test.js.snap | 1 + __tests__/hcaptchaSmsLink.test.js | 115 ++++++++++++ hcaptchaSmsLink.js | 175 ++++++++++++++++++ 6 files changed, 386 insertions(+), 21 deletions(-) create mode 100644 __tests__/hcaptchaSmsLink.test.js create mode 100644 hcaptchaSmsLink.js diff --git a/Hcaptcha.js b/Hcaptcha.js index 2936b4c..05ea168 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -6,6 +6,7 @@ import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion'; import md5 from './md5'; import hcaptchaPackage from './package.json'; +import { parseSmsLink } from './hcaptchaSmsLink'; import { clearJourneyEvents, disableJourneyConsumer, @@ -429,29 +430,68 @@ const Hcaptcha = ({ webViewRef.current.injectJavaScript('loadApiScript(); true;'); }; + const openSmsLink = (smsUrl) => { + const link = parseSmsLink(smsUrl); + + // Rebuilt from the parsed parts rather than passed through: the recipient can arrive with + // formatting a messaging app rejects, and the challenge emits an empty leading query + // parameter (`?&body=`). The body itself is re-encoded unchanged, so the one-time code + // reaches the composer byte-exact. + const target = + link && link.recipient + ? `sms:${link.recipient}${link.body ? `?body=${encodeURIComponent(link.body)}` : ''}` + : smsUrl; + + Linking.openURL(target).catch(() => { + // The rejection message embeds the URL, and the body carries a one-time code, so it is + // deliberately not forwarded: this event reaches host apps that log it. + onMessage({ + nativeEvent: { + data: 'sms-open-failed', + description: 'Could not open the messaging app', + }, + success: false, + }); + }); + }; + + // Shared by both navigation callbacks. The challenge opens its `sms:` link with + // `target="_blank"`, which never reaches `onShouldStartLoadWithRequest` - on Android + // `setSupportMultipleWindows` is always on, so it surfaces as `onOpenWindow` instead. + const handleExternalUrl = (candidate) => { + if (typeof candidate !== 'string') { + return false; + } + + if (candidate.slice(0, 24) === 'https://www.hcaptcha.com') { + Linking.openURL(candidate); + return true; + } + + if (candidate.toLowerCase().startsWith('sms:')) { + openSmsLink(candidate); + return true; + } + + return false; + }; + return ( { - if (event.url.slice(0, 24) === 'https://www.hcaptcha.com') { - Linking.openURL(event.url); - return false; - } else if (event.url.toLowerCase().startsWith('sms:')) { - Linking.openURL(event.url).catch((err) => { - onMessage({ - nativeEvent: { - data: 'sms-open-failed', - description: err.message, - }, - success: false, - }); - }); - return false; + onShouldStartLoadWithRequest={(event) => !handleExternalUrl(event.url)} + onOpenWindow={(event) => { + const { targetUrl } = event.nativeEvent; + if (handleExternalUrl(targetUrl)) { + return; } - return true; + // A `target="_blank"` link the challenge does not own. Android already sent these out + // to the system before this callback existed, so keep that behaviour rather than + // swallowing the navigation. + Linking.openURL(targetUrl).catch(() => {}); }} mixedContentMode={'always'} onMessage={(e) => { diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index a4c793d..76f64a7 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -740,7 +740,7 @@ describe('Hcaptcha', () => { expect(openURL).toHaveBeenCalledWith('https://www.hcaptcha.com/privacy'); }); - it('opens sms links externally and reports failures back through onMessage', async () => { + it('opens sms links externally, normalising the challenge link shape', async () => { const openURL = jest.spyOn(Linking, 'openURL'); const onMessage = jest.fn(); const component = render( @@ -751,17 +751,20 @@ describe('Hcaptcha', () => { /> ); + // The shape the challenge emits, with the empty leading query parameter. openURL.mockResolvedValueOnce(true); const successfulSms = getWebView(component).props.onShouldStartLoadWithRequest({ - url: 'sms:+15551234567', + url: 'sms:+15550001111?&body=Do%20not%20share%20the%20code%3A%20aaaa-bbbb-cccc', }); expect(successfulSms).toBe(false); - expect(openURL).toHaveBeenCalledWith('sms:+15551234567'); + expect(openURL).toHaveBeenCalledWith( + 'sms:+15550001111?body=Do%20not%20share%20the%20code%3A%20aaaa-bbbb-cccc' + ); openURL.mockRejectedValueOnce(new Error('sms unavailable')); const failedSms = getWebView(component).props.onShouldStartLoadWithRequest({ - url: 'sms:+15557654321', + url: 'sms:+15550002222', }); expect(failedSms).toBe(false); @@ -770,13 +773,43 @@ describe('Hcaptcha', () => { expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'sms-open-failed', - description: 'sms unavailable', + // The rejection message embeds the URL, which carries the one-time code, so it is + // deliberately not forwarded. + description: 'Could not open the messaging app', }, success: false, }); }); }); + it('handles target="_blank" links through onOpenWindow', () => { + // The challenge opens its sms link with target="_blank", which never reaches + // onShouldStartLoadWithRequest on Android. + const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); + const component = render( + + ); + + getWebView(component).props.onOpenWindow({ + nativeEvent: { targetUrl: 'sms:+15550001111?&body=code%3A%20aaaa-bbbb-cccc' }, + }); + expect(openURL).toHaveBeenCalledWith('sms:+15550001111?body=code%3A%20aaaa-bbbb-cccc'); + + getWebView(component).props.onOpenWindow({ + nativeEvent: { targetUrl: 'https://www.hcaptcha.com/privacy' }, + }); + expect(openURL).toHaveBeenCalledWith('https://www.hcaptcha.com/privacy'); + + getWebView(component).props.onOpenWindow({ + nativeEvent: { targetUrl: 'https://example.com/other' }, + }); + expect(openURL).toHaveBeenCalledWith('https://example.com/other'); + }); + it('allows non-hcaptcha, non-sms navigations to continue inside the WebView', () => { const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); const component = render( diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index d309654..675bbd1 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -89,6 +89,7 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() javaScriptEnabled={true} mixedContentMode="always" onMessage={[Function]} + onOpenWindow={[Function]} onShouldStartLoadWithRequest={[Function]} originWhitelist={ [ diff --git a/__tests__/__snapshots__/Hcaptcha.test.js.snap b/__tests__/__snapshots__/Hcaptcha.test.js.snap index ee656fd..5ca648c 100644 --- a/__tests__/__snapshots__/Hcaptcha.test.js.snap +++ b/__tests__/__snapshots__/Hcaptcha.test.js.snap @@ -23,6 +23,7 @@ exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = ` javaScriptEnabled={true} mixedContentMode="always" onMessage={[Function]} + onOpenWindow={[Function]} onShouldStartLoadWithRequest={[Function]} originWhitelist={ [ diff --git a/__tests__/hcaptchaSmsLink.test.js b/__tests__/hcaptchaSmsLink.test.js new file mode 100644 index 0000000..341fa35 --- /dev/null +++ b/__tests__/hcaptchaSmsLink.test.js @@ -0,0 +1,115 @@ +import { parseSmsLink } from '../hcaptchaSmsLink'; + +// The shape the MFA challenge emits: an hCaptcha number and a full sentence carrying a +// hyphenated one-time code. The numbers and codes here are dummies. +const POOL_NUMBER = '+15550001111'; +const POOL_BODY = + 'Return to the app and press Confirm after sending this message. ' + + 'Do not edit or share the code: aaaa-bbbb-cccc'; +const POOL_LINK = + 'sms:+15550001111?body=Return%20to%20the%20app%20and%20press%20Confirm%20after%20sending%20' + + 'this%20message.%20Do%20not%20edit%20or%20share%20the%20code%3A%20aaaa-bbbb-cccc'; + +// The same shape with the empty first query parameter the challenge is reported to emit: +// `?&body=`, not `?body=`. +const EMPTY_LEADING_PAIR_LINK = + 'sms:+15550002222?&body=Return%20to%20the%20app%20and%20press%20Confirm%20after%20sending%20' + + 'this%20message.%20Do%20not%20edit%20or%20share%20the%20code%3A%20dddd-eeee-ffff'; + +const NUMBER = '+15551234567'; +const CODE = 'code'; +const CODE_QUERY = '?body=code'; + +/** @param suffix everything after the hCaptcha number in the link */ +const parseSuffix = (suffix) => parseSmsLink(`sms:${NUMBER}${suffix}`); + +describe('parseSmsLink', () => { + it('parses a full challenge-shaped link', () => { + expect(parseSmsLink(POOL_LINK)).toEqual({ recipient: POOL_NUMBER, body: POOL_BODY }); + }); + + it('parses a link with an empty leading query parameter', () => { + expect(parseSmsLink(EMPTY_LEADING_PAIR_LINK)).toEqual({ + recipient: '+15550002222', + body: + 'Return to the app and press Confirm after sending this message. ' + + 'Do not edit or share the code: dddd-eeee-ffff', + }); + }); + + it('skips empty query parameters', () => { + expect(parseSuffix('?&body=code').body).toBe(CODE); + expect(parseSuffix('?&&body=code').body).toBe(CODE); + expect(parseSuffix('?x=1&body=code&').body).toBe(CODE); + }); + + it('keeps the body byte-exact', () => { + // The code is hyphenated in the SMS body and unhyphenated in the challenge UI; the backend + // normalises, so nothing here may trim, reformat or re-case the body. + expect(parseSuffix('?body=%20%20spaced%20%20').body).toBe(' spaced '); + expect(parseSuffix('?body=aaaa-bbbb-cccc').body).toBe('aaaa-bbbb-cccc'); + }); + + it('splits the query before the subscriber params', () => { + // RFC 5724 allows `;` subscriber params before the query. Folding the two grammars together + // loses the body. + expect(parseSuffix(`;phone-context=+1${CODE_QUERY}`)).toEqual({ + recipient: NUMBER, + body: CODE, + }); + }); + + it('treats a semicolon inside the query as body text', () => { + expect(parseSuffix('?body=code;more').body).toBe('code;more'); + }); + + it('reads the legacy body parameter from the head', () => { + expect(parseSuffix(';body=hello').body).toBe('hello'); + }); + + it('keeps a plus in the body', () => { + // `+` is a literal plus in a URI query (RFC 3986); only form encoding reads it as a space. + expect(parseSuffix('?body=a+b').body).toBe('a+b'); + }); + + it('decodes utf-8 escapes', () => { + expect(parseSuffix('?body=caf%C3%A9%20%E2%80%93%20ok').body).toBe('café – ok'); + }); + + it('keeps malformed escapes as literal text', () => { + expect(parseSuffix('?body=100%25').body).toBe('100%'); + expect(parseSuffix('?body=50%').body).toBe('50%'); + expect(parseSuffix('?body=%zz').body).toBe('%zz'); + }); + + it('normalises recipient formatting', () => { + expect(parseSmsLink('sms:+123-456-789?body=Hello%20World').recipient).toBe('+123456789'); + expect(parseSmsLink('sms:%2B1%20(555)%20123.4567').recipient).toBe(NUMBER); + }); + + it('keeps only the first recipient', () => { + expect(parseSuffix(`,+15559999999${CODE_QUERY}`).recipient).toBe(NUMBER); + }); + + it('accepts the smsto and authority forms', () => { + expect(parseSmsLink(`smsto:${NUMBER}${CODE_QUERY}`).recipient).toBe(NUMBER); + expect(parseSmsLink(`sms://${NUMBER}${CODE_QUERY}`).body).toBe(CODE); + expect(parseSmsLink(`SMS:${NUMBER}?BODY=code`).body).toBe(CODE); + }); + + it('reports missing parts as null', () => { + expect(parseSuffix('').body).toBeNull(); + expect(parseSuffix('?body=').body).toBeNull(); + expect(parseSuffix('?subject=hi').body).toBeNull(); + expect(parseSmsLink(`sms:${CODE_QUERY}`).recipient).toBeNull(); + expect(parseSmsLink('sms:not-a-number?body=nope').recipient).toBeNull(); + }); + + it('rejects non-sms urls', () => { + expect(parseSmsLink(null)).toBeNull(); + expect(parseSmsLink(undefined)).toBeNull(); + expect(parseSmsLink(`https://example.com${CODE_QUERY}`)).toBeNull(); + expect(parseSmsLink(`tel:${NUMBER}`)).toBeNull(); + expect(parseSmsLink(`smsx:${NUMBER}`)).toBeNull(); + }); +}); diff --git a/hcaptchaSmsLink.js b/hcaptchaSmsLink.js new file mode 100644 index 0000000..abbbbae --- /dev/null +++ b/hcaptchaSmsLink.js @@ -0,0 +1,175 @@ +/** + * Parsing for the `sms:` URLs emitted by the MFA inbound-SMS challenge. + * + * Covers the RFC 5724 grammar - `sms:recipients[;subscriber-params][?query]` - as well as the + * legacy `;body=` form. + * + * The body carries a one-time code that the backend validates against the message it actually + * receives, so it is passed through byte-exact: percent-decoded and nothing else. It must never + * be trimmed, reformatted or logged. + */ + +const SMS_SCHEME = 'sms:'; +const SMSTO_SCHEME = 'smsto:'; +const BODY_KEY = 'body'; +const QUERY_SEPARATORS = '&'; +const HEAD_SEPARATORS = ';&'; + +const isHexDigit = (char) => /^[0-9a-fA-F]$/.test(char); + +/** + * Percent-decodes as UTF-8, leaving anything that is not a well-formed `%XX` escape as literal + * text. + * + * `+` is deliberately left alone: it means a literal plus in a URI query (RFC 3986), and only + * means a space in form encoding, which `sms:` links do not use. This rules out `URLSearchParams` + * and `querystring.parse`, both of which apply form decoding. + */ +const percentDecode = (value) => { + if (value.indexOf('%') < 0) { + return value; + } + + let decoded = ''; + let index = 0; + while (index < value.length) { + if ( + value[index] !== '%' || + index + 3 > value.length || + !isHexDigit(value[index + 1]) || + !isHexDigit(value[index + 2]) + ) { + decoded += value[index]; + index++; + continue; + } + + // Consume the whole run of escapes at once so a multi-byte UTF-8 character split across + // several of them decodes as one character. + let end = index; + while ( + end + 3 <= value.length && + value[end] === '%' && + isHexDigit(value[end + 1]) && + isHexDigit(value[end + 2]) + ) { + end += 3; + } + + const run = value.slice(index, end); + try { + decoded += decodeURIComponent(run); + } catch (e) { + // Escapes that are individually well-formed but not valid UTF-8 together. + decoded += run; + } + index = end; + } + + return decoded; +}; + +const indexOfAny = (value, characters, from) => { + for (let index = from; index < value.length; index++) { + if (characters.indexOf(value[index]) >= 0) { + return index; + } + } + return -1; +}; + +/** + * Reads a single parameter out of a `separators`-delimited list. + * + * Decoding happens after splitting so an encoded separator inside a value survives. Empty pairs + * are skipped rather than treated as the end of the list: the challenge is reported to emit + * `?&body=`, so the first pair is empty. + */ +const readValue = (key, parameters, separators) => { + let cursor = 0; + while (cursor < parameters.length) { + const next = indexOfAny(parameters, separators, cursor); + const end = next < 0 ? parameters.length : next; + const item = parameters.slice(cursor, end); + cursor = end + 1; + + const equals = item.indexOf('='); + const name = equals < 0 ? item : item.slice(0, equals); + if (name.toLowerCase() === key) { + const decoded = percentDecode(equals < 0 ? '' : item.slice(equals + 1)); + return decoded === '' ? null : decoded; + } + } + + return null; +}; + +/** + * The challenge only ever targets a single hCaptcha number, so any extra recipients are dropped. + * + * The surviving characters are an allowlist rather than a denylist: formatting can arrive as + * spaces, dashes, parens or dots, and anything left in that the messaging app rejects makes it + * silently drop the recipient. + */ +const firstRecipient = (list) => { + const comma = list.indexOf(','); + const decoded = percentDecode(comma < 0 ? list : list.slice(0, comma)); + + let normalized = ''; + for (let index = 0; index < decoded.length; index++) { + const current = decoded[index]; + if (current === '+' || (current >= '0' && current <= '9')) { + normalized += current; + } + } + + return normalized === '' ? null : normalized; +}; + +/** + * Parses an `sms:` or `smsto:` URL. + * + * @param {string} [url] the URL the WebView tried to navigate to + * @returns {{recipient: ?string, body: ?string}|null} the parsed link, or null for any URL that + * is not an SMS link + */ +export const parseSmsLink = (url) => { + if (typeof url !== 'string') { + return null; + } + + const lowercased = url.toLowerCase(); + let start; + if (lowercased.startsWith(SMS_SCHEME)) { + start = SMS_SCHEME.length; + } else if (lowercased.startsWith(SMSTO_SCHEME)) { + start = SMSTO_SCHEME.length; + } else { + return null; + } + + // Tolerate the authority-style `sms://` spelling seen in the wild. + while (start < url.length && url[start] === '/') { + start++; + } + const payload = url.slice(start); + + // The query and the `;` subscriber parameters are separate grammars, so `?` has to be split off + // first. Folding them together loses the body of a link such as + // `sms:+15551234567;phone-context=+1?body=code`. + const mark = payload.indexOf('?'); + const head = mark < 0 ? payload : payload.slice(0, mark); + const query = mark < 0 ? '' : payload.slice(mark + 1); + + const headParamsStart = indexOfAny(head, HEAD_SEPARATORS, 0); + const recipients = headParamsStart < 0 ? head : head.slice(0, headParamsStart); + const headParams = headParamsStart < 0 ? '' : head.slice(headParamsStart + 1); + + // A `;` inside a query is body text, not a separator (RFC 3986 lists it as a sub-delim), so the + // query is split on `&` alone. The legacy `;body=` form is read from the head. + const body = + readValue(BODY_KEY, query, QUERY_SEPARATORS) || + readValue(BODY_KEY, headParams, HEAD_SEPARATORS); + + return { recipient: firstRecipient(recipients), body }; +};