Skip to content

fix: enforce DNS label and hostname length limits in ExpensiMark - #951

Open
Uzaifm127 wants to merge 23 commits into
Expensify:mainfrom
Uzaifm127:fix/102500-validate-url-hostnames
Open

Uzaifm127 wants to merge 23 commits into
Expensify:mainfrom
Uzaifm127:fix/102500-validate-url-hostnames

Conversation

@Uzaifm127

@Uzaifm127 Uzaifm127 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

ExpensiMark didn't enforce DNS length limits when validating URL hostnames. Because of this, it could treat a hostname with an oversized label as a valid URL even though the hostname violated DNS rules.

This PR adds the two DNS hostname limits used by ExpensiMark:

  • A single hostname label can't exceed 63 ASCII characters.
  • The complete hostname can't exceed 253 ASCII characters.

These limits are applied in both the URL regular expressions and the optimized candidate scanner so that they continue to produce the same result. The scanner now rejects oversized labels and hostnames before passing them to the URL regex, while the regex prevents an invalid hostname or a valid-looking suffix within it from being matched.

Only the hostname is included in these checks. The protocol, port, path, query parameters, fragment, and trailing punctuation remain outside the hostname-length calculation, so URLs with a valid hostname and a long path continue to work normally.

Fixed Issues

$ #102500
PROPOSAL:

Tests

  1. Open the App.

  2. Open any chat.

  3. On Web, open Chrome DevTools and select the Console tab.

  4. Run:

    copy('a'.repeat(8496) + '.com')
  5. Paste the copied text into the composer and add a space after .com.

  6. Verify that:

    • The text remains plain and is not converted into a link.
    • The maximum comment length error is not displayed.
  • Verify that no errors appear in the JS console

Offline tests

Same as Test

QA Steps

Same as Test

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If the PR adds or modifies the UI:
    • I asked an AI agent to review the changes for accessibility issues and addressed its findings.
    • I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order.
    • I verified all new/changed elements have meaningful accessible names and roles.
    • I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected).
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android-native-1.mov
Android-native-2.mov
Android-native-3.mov
Android: mWeb Chrome
Android-mweb-1.mov
Android-mweb-2.mov
Android-mweb-3.mov
iOS: Native
iOS-native-1.mp4
iOS-native-2.mov
iOS-native-3.mov
iOS: mWeb Safari
iOS-safari-1.mov
iOS-safari-2.mov
iOS-safari-3.mov
MacOS: Chrome / Safari
macOS-1.mp4
macOS-2.mp4

Reject URLs with hostname labels longer than 63 characters or complete hostnames longer than 253 characters in both the URL regexes and optimized candidate scanner.
Keep the published URL regex declarations aligned with the new hostname limits.
Keep invalid hostname candidates unchanged instead of autolinking valid-looking prefixes or suffixes, while preserving valid URL boundary behavior.
Check hostname limits during candidate scanning so invalid long URLs stay plain without triggering expensive URL-regex backtracking.
Resume candidate scanning after punctuation that ends an oversized hostname so a following valid URL is still parsed.
@Uzaifm127 Uzaifm127 changed the title fix: enforce DNS label and hostname length limits in ExpensiMark [WIP] fix: enforce DNS label and hostname length limits in ExpensiMark Oct 3, 2026
Use the same invalid URL range for bare URLs, protocol URLs, and raw HTML parsing, so invalid URL text stays plain while a separate valid URL still gets autolinked.
Share hostname validation and connected URL-range handling between candidate scanning and regex parsing so malformed URLs with paths or queries stay plain while separate valid URLs remain linkable.
@Uzaifm127

Copy link
Copy Markdown
Contributor Author

Existing issues found while testing

These issues already existed before the hostname-validation changes and are separate from the regressions and suggestions listed above.

  1. Bug: Nonexistent room names are highlighted as report mentions

Any text that follows the report mention syntax can receive report mention styling, even when no room with that name exists.

Reproduction steps:

  1. Open a workspace chat.
  2. Type a room name that does not exist, such as #this-room-does-not-exist.
  3. Add a space after the name.
  4. Observe the formatting applied to the text.

Expected behaviour: A room name that does not match an existing report should remain plain text.

Actual behaviour: The nonexistent room name is highlighted as a report mention.

Platforms: Reproduced on web.

report-mention-issue.mov

Root cause

ExpensiMark converts text matching the #room-name syntax into a mention-report range without knowing which rooms exist.

In a workspace chat composer, App uses that parsed range directly for styling. App has a resolution step for short @user mentions, but it has no equivalent step for mention-report ranges before applying the style. Therefore, a syntactically valid but nonexistent room name is highlighted.

name: 'reportMentions',
regex: /(?<=^[*~_:]?|[ \n][*~_:]?|[:#])(#[\p{Ll}0-9-]{1,99})(?![^<]*(?:<\/pre>|<\/code>|<\/a>))/gimu,
replacement: '<mention-report>$1</mention-report>',

https://github.com/Expensify/App/blob/3b78e268370fae6018c6bbf1b76136abbc614974/src/libs/ParsingUtils.ts#L18-L56

https://github.com/Expensify/App/blob/3b78e268370fae6018c6bbf1b76136abbc614974/src/components/Composer/implementation/index.tsx#L70

  1. Bug: Invalid IPv4 addresses are converted into links

Protocol URLs containing invalid IPv4 octets are accepted as links.

Reproduction steps:

  1. Open any chat.
  2. Type http://256.1.1.1.
  3. Add a space after the address.
  4. Observe whether the address becomes a link.

Expected behaviour: http://256.1.1.1 should remain plain because an IPv4 octet cannot exceed 255.

Actual behaviour: The invalid IPv4 address becomes a link.

Platforms: Reproduced on web.

IPv4-issue.mov

Root cause

Protocol URLs use the loose hostname regex. That regex accepts one or more numeric hostname labels but does not distinguish an IPv4 address from a normal hostname or validate each numeric octet against the 0–255 range.

The candidate scanner also validates numeric values as ordinary hostname labels, so 256.1.1.1 passes both parser stages.

const LOOSE_URL_WEBSITE_REGEX = `${URL_PROTOCOL_REGEX}${URL_HOSTNAME_MAX_LENGTH_ASSERTION}(${LOOSE_URL_HOSTNAME_LABEL_REGEX}(?:\\.${LOOSE_URL_HOSTNAME_LABEL_REGEX})*)(?:\\:${ALLOWED_PORTS}|\\b|(?=_))`;
const LOOSE_URL_REGEX = `((${LOOSE_URL_WEBSITE_REGEX})${URL_PATH_REGEX}(?:${URL_PARAM_REGEX}|${URL_FRAGMENT_REGEX})*)`;
const MARKDOWN_URL_REGEX = `(${LOOSE_URL_REGEX}|${URL_REGEX})`;

function isValidHostnameLabel(text: string, start: number, end: number): boolean {
if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) {
return false;
}
for (let index = start + 1; index < end - 1; index++) {
if (!isAsciiAlphaNumeric(text[index]) && text[index] !== '-') {
return false;
}
}
return true;

function isValidHostname(text: string, hostnameStart: number, hostnameEnd: number): boolean {
let hostnameContentEnd = hostnameEnd;
while (hostnameContentEnd > hostnameStart && (text[hostnameContentEnd - 1] === '.' || text[hostnameContentEnd - 1] === '-')) {
hostnameContentEnd--;
}
if (hostnameContentEnd === hostnameStart || hostnameContentEnd - hostnameStart > Constants.MAX_URL_HOSTNAME_LENGTH) {
return false;
}
let labelStart = hostnameStart;
for (let index = hostnameStart; index <= hostnameContentEnd; index++) {
if (index !== hostnameContentEnd && text[index] !== '.') {
continue;
}
if (index - labelStart > Constants.MAX_URL_HOSTNAME_LABEL_LENGTH || !isValidHostnameLabel(text, labelStart, index)) {
return false;
}
labelStart = index + 1;
}

  1. Bug: Valid IPv6 URLs are not converted into links

URLs containing a bracketed IPv6 address remain plain even when the IPv6 address and URL are valid.

Reproduction steps:

  1. Open any chat.
  2. Type http://[::1]/path.
  3. Add a space after the URL.
  4. Repeat with https://[2001:db8::1]:8443/path.
  5. Observe whether either URL becomes a link.

Expected behaviour: Both valid IPv6 URLs should become links.

Actual behaviour: Both URLs remain plain text.

Platforms: Reproduced on web.

IPv6.mov

Root cause

The candidate scanner only treats ASCII letters, digits, hyphens, and dots as hostname characters. It stops immediately when an IPv6 hostname starts with [.

The protocol URL regex also supports only dot-separated hostname labels. It does not support the bracket and colon syntax required by IPv6 URL literals.

/** Returns whether the character can be part of a hostname such as example.com. */
function isHostnameCharacter(character?: string): boolean {
return !!character && (isAsciiAlphaNumeric(character) || character === '-' || character === '.');
}

function findHostnameEnd(text: string, hostnameStart: number, candidateEnd: number): number {
let hostnameEnd = hostnameStart;
while (hostnameEnd < candidateEnd && isHostnameCharacter(text[hostnameEnd])) {
hostnameEnd++;
}
return hostnameEnd;

const LOOSE_URL_WEBSITE_REGEX = `${URL_PROTOCOL_REGEX}${URL_HOSTNAME_MAX_LENGTH_ASSERTION}(${LOOSE_URL_HOSTNAME_LABEL_REGEX}(?:\\.${LOOSE_URL_HOSTNAME_LABEL_REGEX})*)(?:\\:${ALLOWED_PORTS}|\\b|(?=_))`;
const LOOSE_URL_REGEX = `((${LOOSE_URL_WEBSITE_REGEX})${URL_PATH_REGEX}(?:${URL_PARAM_REGEX}|${URL_FRAGMENT_REGEX})*)`;
const MARKDOWN_URL_REGEX = `(${LOOSE_URL_REGEX}|${URL_REGEX})`;


Note

I'm mentioning the following fourth bug because I noticed that but I'm not sure whether it is an actual bug or not because whatsapp doesn't allow autolinking the URL with invalid TLD while Slack and github does.

  1. Bug: Protocol URLs with an unrecognized public TLD are converted into links

Note

This is only reproducible with URL having protocol http or https.

We didn't cover this issue in this PR because this PR only enforces DNS hostname limits: each label can contain up to 63 characters, and the complete hostname can contain up to 253 characters, so this is out of scope of this PR.

Protocol URLs can become links even when their final hostname label is not in the known public TLD list.

Reproduction steps:

  1. Open any chat.
  2. Type https://example.comdffsdfsdfsdfs.
  3. Add a space after the URL.
  4. Observe whether the URL becomes a link.

Expected behaviour: https://example.comdffsdfsdfsdfs should remain plain text because its final hostname label is not a recognized public TLD.

Actual behaviour: The URL becomes a link.

Platforms: Reproduced on web.


@JS00001 We found these small existing issues while testing the PR.

If you agree, after this PR is merged, @linhvovan29546 and I can work on them as small follow-up PRs from this investigation:

  1. An App PR for the nonexistent #room-name mention styling, since it needs the current workspace report list.
  2. An expensify-common PR for invalid IPv4 URLs and valid bracketed IPv6 URLs, since both belong to protocol URL host parsing and need the candidate scanner and URL regex to stay aligned.

For the invalid TLD autolinking URL issue (the fourth one), I just mentioned that what I found but I'm not sure whether to treat this as a bug or not because some apps like whatsapp doesn't allow autolinking the URLs with invalid TLD while some like github or slack does. If it would be treated as a bug then it will need a separate follow up PR.

WDYT?

@Uzaifm127
Uzaifm127 marked this pull request as ready for review October 6, 2026 15:26
@Uzaifm127
Uzaifm127 requested a review from a team as a code owner October 6, 2026 15:26
@Uzaifm127 Uzaifm127 changed the title [WIP] fix: enforce DNS label and hostname length limits in ExpensiMark fix: enforce DNS label and hostname length limits in ExpensiMark Oct 6, 2026
@melvin-bot
melvin-bot Bot requested review from carlosmiceli and removed request for a team October 6, 2026 15:26
@Uzaifm127

Copy link
Copy Markdown
Contributor Author

@carlosmiceli This PR will be reviewed by @JS00001 and @linhvovan29546

Thanks

@carlosmiceli
carlosmiceli requested review from JS00001 and removed request for carlosmiceli October 6, 2026 18:00
@JS00001

JS00001 commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@linhvovan29546 can you comment for assignment here? It wont let me assign you

@linhvovan29546

Copy link
Copy Markdown

Reviewing...

@JS00001

JS00001 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

I still cant assign you to review 😕 , are you able to review even without me requesting it in gh?

@linhvovan29546

Copy link
Copy Markdown

I still cant assign you to review 😕 , are you able to review even without me requesting it in gh?

Yes

@linhvovan29546

Copy link
Copy Markdown

Problem: When a message contains a URL with an otherwise valid hostname and TLD (e.g. https://example.com), if a user types or pastes that URL directly followed, with no space, by text that makes the combined hostname-class run invalid (e.g. a missed space after a sentence-ending period https://example.com.Thanks! or a doubled punctuation mark https://example.com..nonsense), then analyzeUrlRange/findHostnameEnd (lib/ExpensiMark.ts:416-424) validates the entire greedily-extended run instead of just the real hostname+TLD, marks the match as invalid, and the URL is not autolinked at all, which means a previously-working, everyday chat pattern goes completely unlinked with no visual fallback — a regression from the current behavior on main, where the valid portion (https://example.com) still linked correctly and only the trailing junk was left as plain text.

Repro:

Input:  "Check https://example.com..nonsense"
main:   links https://example.com, leaves "..nonsense" as plain text
PR:     nothing is linked

@linhvovan29546

Copy link
Copy Markdown

Expected behaviour: http://256.1.1.1 should remain plain because an IPv4 octet cannot exceed 255.

I think this is not a bug. Slack (and likely most chat/messaging products) do the same thing

Comment thread lib/ExpensiMark.ts Outdated
Comment thread lib/Url.js
@Uzaifm127

Uzaifm127 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor Author

I'll go through the review comments ☝️ on tomorrow noon, thanks.

Edit: looking...

@Uzaifm127

Uzaifm127 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

This was also the regression (comment), which got fixed in this commit


@linhvovan29546 From your comment, I think you meant this: example.com.Thanks! instead of this: https://example.com.Thanks! because for this case, the bare domain case was actually a regression.

@Uzaifm127

Uzaifm127 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Update: I'm looking for more regressions in the PR, I'll ping here once I finished finding the regressions and fixing it. Thanks.

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.

3 participants