Skip to content

Fix stateful phone-context validation regexes (drop the g flag) - #498

Open
spokodev wants to merge 1 commit into
catamphetamine:masterfrom
spokodev:fix/phone-context-regex-lastindex
Open

Fix stateful phone-context validation regexes (drop the g flag)#498
spokodev wants to merge 1 commit into
catamphetamine:masterfrom
spokodev:fix/phone-context-regex-lastindex

Conversation

@spokodev

Copy link
Copy Markdown

What

isPhoneContextValid() validates a tel: URI's phone-context parameter with two module-level regexes:

const RFC3966_GLOBAL_NUMBER_DIGITS_PATTERN_ = new RegExp(RFC3966_GLOBAL_NUMBER_DIGITS_, 'g')
const RFC3966_DOMAINNAME_PATTERN_ = new RegExp(RFC3966_DOMAINNAME_, 'g')
// ...
return RFC3966_GLOBAL_NUMBER_DIGITS_PATTERN_.test(phoneContext) ||
	RFC3966_DOMAINNAME_PATTERN_.test(phoneContext)

Both are created with the g flag but used only via RegExp#test(). On a global regex, test() advances the regex's lastIndex and resumes from it next time. Since both patterns are anchored (^…$, no m flag), the following call starts mid-string, ^ can't match, so it returns false and resets lastIndex to 0.

The regex objects persist across every parse, so phone-context validation alternates valid/invalid for the same input, and leaks state between unrelated numbers:

parsePhoneNumber('tel:12133734253;phone-context=+1').number // '+12133734253'
parsePhoneNumber('tel:12133734253;phone-context=+1')         // undefined  ← same valid input

parsePhoneNumber('tel:12133734253;phone-context=+1').number // '+12133734253'
parsePhoneNumber('tel:2079460958;phone-context=+44')         // undefined  ← poisoned by the previous call

Numbers without a phone-context are unaffected (they short-circuit before these regexes), which is why it hides in normal use and surfaces in tel: URI / batch scenarios.

Fix

The g flag serves no purpose for a full-string anchored test(). Removing it from both patterns makes validation stateless and deterministic.

Test

Added a case asserting isPhoneContextValid returns the same result across repeated calls — '+1' exercises the global-number-digits pattern and 'a' the domainname pattern. It fails on the current code (the second call returns false) and passes with the fix.

`RFC3966_GLOBAL_NUMBER_DIGITS_PATTERN_` and `RFC3966_DOMAINNAME_PATTERN_`
are module-level regexes created with the `g` flag and used only via
`RegExp#test()` in `isPhoneContextValid`. `test()` on a global regex
advances that regex's `lastIndex`, and since both patterns are anchored
(`^...$`, no `m` flag), the next call starts mid-string, `^` no longer
matches, and it returns `false` before resetting `lastIndex` to 0.

Because the regex objects persist across every parse, this makes
phone-context validation alternate valid/invalid for the same input and
leak state between unrelated numbers:

  parsePhoneNumber('tel:12133734253;phone-context=+1') // +12133734253
  parsePhoneNumber('tel:12133734253;phone-context=+1') // undefined (!)

The `g` flag serves no purpose for a full-string anchored `test()`, so
remove it from both patterns. Validation is now stateless and returns the
same result on every call.
@catamphetamine

catamphetamine commented Jul 23, 2026 via email

Copy link
Copy Markdown
Owner

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants