From 00fe03d428c7048e1f45290fda3454b7356cad8c Mon Sep 17 00:00:00 2001 From: Uzaifm127 Date: Thu, 1 Oct 2026 20:01:22 +0530 Subject: [PATCH 01/24] fix(expensimark): enforce DNS hostname length limits 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. --- __tests__/ExpensiMark-HTML-test.js | 64 +++++++++++++++++++++++++---- __tests__/URL-test.js | 66 ++++++++++++++++++++++++++++++ lib/CONST.ts | 8 +++- lib/ExpensiMark.ts | 15 ++++--- lib/Url.js | 10 ++++- 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/__tests__/ExpensiMark-HTML-test.js b/__tests__/ExpensiMark-HTML-test.js index 548c6f36c..ca0cf4a09 100644 --- a/__tests__/ExpensiMark-HTML-test.js +++ b/__tests__/ExpensiMark-HTML-test.js @@ -617,30 +617,76 @@ test('Test wrapped URLs', () => { describe('Test long input candidate parsing', () => { const anchor = (url, label = url) => `${label}`; - test('autolinks a long domain without parsing the full input as a URL', () => { - const domain = `${'a'.repeat(70)}.com`; + test('autolinks a domain with the maximum hostname label length without parsing the full input as a URL', () => { + // Given a valid 63-character hostname label after a long plain-text prefix. + const domain = `${'a'.repeat(63)}.com`; const prefix = 'a'.repeat(14500); - expect(parser.replace(`${prefix} ${domain}`)).toBe(`${prefix} ${anchor(domain)}`); + + // When ExpensiMark parses the complete message. + const result = parser.replace(`${prefix} ${domain}`); + + // Then it autolinks the valid domain without treating the long prefix as part of the URL. + expect(result).toBe(`${prefix} ${anchor(domain)}`); }); - test('handles long valid and invalid bare-domain candidates without changing their output', () => { - // Given long valid and invalid domain-shaped values, because the optimization must not change what users see while typing. - const hostname = 'a'.repeat(8496); - const validUrl = `${hostname}.com`; - const invalidUrl = `${validUrl}x`; + test('does not autolink bare domains whose hostname label exceeds 63 characters', () => { + // Given domains at and above the DNS hostname-label limit, including the long value from the reported composer issue. + const validUrl = `${'a'.repeat(63)}.com`; + const invalidUrl = `${'a'.repeat(64)}.com`; + const reportedInvalidUrl = `${'a'.repeat(8496)}.com`; + const invalidTld = `${reportedInvalidUrl}x`; const repeatedDots = `${'a.'.repeat(4499)}a`; // When ExpensiMark parses each value. const validResult = parser.replace(validUrl); const invalidResult = parser.replace(invalidUrl); + const reportedInvalidResult = parser.replace(reportedInvalidUrl); + const invalidTldResult = parser.replace(invalidTld); const repeatedDotsResult = parser.replace(repeatedDots); - // Then it links only the valid domain, which protects both the expected output and responsive typing for invalid long input. + // Then it links only the domain whose labels are valid and leaves oversized or unknown domains as plain text. expect(validResult).toBe(anchor(validUrl)); expect(invalidResult).toBe(invalidUrl); + expect(reportedInvalidResult).toBe(reportedInvalidUrl); + expect(invalidTldResult).toBe(invalidTld); expect(repeatedDotsResult).toBe(repeatedDots); }); + test('does not autolink URLs whose complete hostname exceeds 253 characters', () => { + // Given hostnames at and above the DNS length limit, with every individual label remaining valid. + const validHostname = `${'a.'.repeat(125)}com`; + const invalidHostname = `aa.${'a.'.repeat(124)}com`; + const invalidProtocolUrl = `https://${invalidHostname}/path`; + + // When ExpensiMark parses bare and protocol URLs at those boundaries. + const validResult = parser.replace(validHostname); + const validWithPunctuationResult = parser.replace(`${validHostname}.`); + const invalidResult = parser.replace(invalidHostname); + const invalidProtocolResult = parser.replace(invalidProtocolUrl); + + // Then it links the valid hostname without counting punctuation and leaves oversized hostnames plain. + expect(validResult).toBe(anchor(validHostname)); + expect(validWithPunctuationResult).toBe(`${anchor(validHostname)}.`); + expect(invalidResult).toBe(invalidHostname); + expect(invalidProtocolResult).toBe(invalidProtocolUrl); + }); + + test.each([ + ['labeled link', (url) => `[label](${url}/path)`], + ['image', (url) => `![alt](${url}/image.png)`], + ['video', (url) => `![video](${url}/video.mp4)`], + ])('does not parse a %s whose complete hostname exceeds 253 characters', (_name, createInput) => { + // Given URL-based Markdown whose hostname exceeds the DNS limit without exceeding any individual label limit. + const hostname = `aa.${'a.'.repeat(124)}com`; + const input = createInput(`https://${hostname}`); + + // When ExpensiMark parses the Markdown. + const result = parser.replace(input); + + // Then it leaves the invalid URL-based Markdown unchanged instead of generating HTML with that hostname. + expect(result).toBe(input); + }); + test('preserves the ftps protocol when autolinking a candidate', () => { const input = 'ftps://example.com/file'; expect(parser.replace(input)).toBe('ftps://example.com/file'); diff --git a/__tests__/URL-test.js b/__tests__/URL-test.js index dd42d45ac..510d007af 100644 --- a/__tests__/URL-test.js +++ b/__tests__/URL-test.js @@ -65,3 +65,69 @@ describe('Loose URL validation', () => { expect(regexToTest.test('http://my.localhost....local-domain:8080')).toBeFalsy(); }); }); + +describe('Hostname label length validation', () => { + it.each([ + ['URL_REGEX', URL_REGEX, `${'a'.repeat(63)}.com`, `${'a'.repeat(64)}.com`], + ['URL_REGEX_WITH_REQUIRED_PROTOCOL', URL_REGEX_WITH_REQUIRED_PROTOCOL, `https://${'a'.repeat(63)}.com`, `https://${'a'.repeat(64)}.com`], + ['LOOSE_URL_REGEX', LOOSE_URL_REGEX, `https://${'a'.repeat(63)}.local`, `https://${'a'.repeat(64)}.local`], + ])('%s accepts 63-character labels and rejects longer labels', (_name, pattern, validUrl, invalidUrl) => { + // Given a URL whose hostname label is at the DNS limit and one whose label exceeds it. + const regexToTest = new RegExp(pattern, 'i'); + + // When each complete URL is checked by the corresponding URL pattern. + const validMatch = regexToTest.exec(validUrl); + regexToTest.lastIndex = 0; + const invalidMatch = regexToTest.exec(invalidUrl); + + // Then the valid URL matches fully and the invalid URL cannot be partially matched from inside its hostname label. + expect(validMatch && validMatch[0]).toBe(validUrl); + expect(invalidMatch).toBeNull(); + }); + + it.each([ + ['URL_REGEX', URL_REGEX, ''], + ['URL_REGEX_WITH_REQUIRED_PROTOCOL', URL_REGEX_WITH_REQUIRED_PROTOCOL, 'https://'], + ['LOOSE_URL_REGEX', LOOSE_URL_REGEX, 'https://'], + ])('%s accepts 253-character hostnames and rejects longer hostnames', (_name, pattern, prefix) => { + // Given hostnames at and above the DNS length limit, with every individual label remaining valid. + const validHostname = `${'a.'.repeat(125)}com`; + const invalidHostname = `aa.${'a.'.repeat(124)}com`; + const regexToTest = new RegExp(pattern, 'i'); + + // When each complete URL is checked by the corresponding URL pattern. + const validUrl = `${prefix}${validHostname}`; + const invalidUrl = `${prefix}${invalidHostname}`; + const validMatch = regexToTest.exec(validUrl); + regexToTest.lastIndex = 0; + const invalidMatch = regexToTest.exec(invalidUrl); + + // Then only the URL whose complete hostname is within the limit matches. + expect(validMatch && validMatch[0]).toBe(validUrl); + expect(invalidMatch).toBeNull(); + }); + + it('does not count a URL path toward the hostname limit', () => { + // Given a URL with a valid hostname and a path that makes the complete URL longer than 253 characters. + const url = `https://example.com/${'a'.repeat(1000)}`; + const regexToTest = new RegExp(`^${URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i'); + + // When the complete URL is checked. + const isValid = regexToTest.test(url); + + // Then it remains valid because only the hostname is subject to the 253-character limit. + expect(isValid).toBeTruthy(); + }); + + it('keeps protocol IPv4 URLs valid', () => { + // Given an IPv4 URL that is supported by the loose URL pattern. + const ipv4Url = 'http://127.0.0.1/path'; + const regexToTest = new RegExp(`^${LOOSE_URL_REGEX}$`, 'i'); + + // When the URL is checked after applying hostname-label limits. + const isValid = regexToTest.test(ipv4Url); + + // Then its numeric labels remain valid because none exceeds the new limit. + expect(isValid).toBeTruthy(); + }); +}); diff --git a/lib/CONST.ts b/lib/CONST.ts index 8d98ca5b2..808ca943f 100644 --- a/lib/CONST.ts +++ b/lib/CONST.ts @@ -3,6 +3,12 @@ const EMAIL_BASE_REGEX = "(?=((?=[\\w'#%+-]+(?:\\.[\\w'#%+-]+)*@)[\\w\\.'#%+-]{1,64}@(?:(?=[a-z\\d]+(?:-+[a-z\\d]+)*\\.)(?:[a-z\\d-]{1,63}\\.)+[a-z]{2,63})(?= |_|\\b))(?.*))\\S{3,254}(?=\\k$)"; +// DNS hostname labels are limited to 63 octets. URL hostnames are ASCII here, so characters and octets are equivalent. +const MAX_URL_HOSTNAME_LABEL_LENGTH = 63; + +// DNS names can use 255 wire octets, which allows at most 253 visible ASCII characters without a trailing root dot. +const MAX_URL_HOSTNAME_LENGTH = 253; + const MOMENT_FORMAT_STRING = 'YYYY-MM-DD'; /** @@ -1940,4 +1946,4 @@ const PUBLIC_DOMAINS_SET = new Set([ 'ymail.com', ]); -export {g_cloudFront, g_cloudFrontImg, CONST, UI, PUBLIC_DOMAINS_SET}; +export {g_cloudFront, g_cloudFrontImg, CONST, UI, PUBLIC_DOMAINS_SET, MAX_URL_HOSTNAME_LABEL_LENGTH, MAX_URL_HOSTNAME_LENGTH}; diff --git a/lib/ExpensiMark.ts b/lib/ExpensiMark.ts index 94ce51f25..8575f158b 100644 --- a/lib/ExpensiMark.ts +++ b/lib/ExpensiMark.ts @@ -263,14 +263,14 @@ function getProtocolAt(text: string, position: number) { } /** - * Returns whether one dot-separated hostname label is valid. + * Returns whether one dot-separated hostname label follows the supported ASCII syntax and length limit. * * @param text - Candidate URL text containing the label. * @param start - Index of the label's first character. * @param end - Index immediately after the label's last character. */ function isValidHostnameLabel(text: string, start: number, end: number): boolean { - if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + if (start >= end || end - start > Constants.MAX_URL_HOSTNAME_LABEL_LENGTH || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { return false; } @@ -287,9 +287,10 @@ function isValidHostnameLabel(text: string, start: number, end: number): boolean * * @param text - Candidate URL text containing the hostname. * @param dotPosition - Index of the dot immediately before the top-level domain. + * @param hostnameEnd - Index immediately after the top-level domain. * @returns The hostname's first-character index, or undefined when no valid hostname precedes the dot. */ -function findHostnameStart(text: string, dotPosition: number): number | undefined { +function findHostnameStart(text: string, dotPosition: number, hostnameEnd: number): number | undefined { let hostnameStart = dotPosition; let labelEnd = dotPosition; @@ -310,7 +311,11 @@ function findHostnameStart(text: string, dotPosition: number): number | undefine } hostnameStart = labelStart; - // A leading hyphen ends the hostname, but the existing URL regex can still match the valid suffix after it. + if (hostnameEnd - hostnameStart > Constants.MAX_URL_HOSTNAME_LENGTH) { + return undefined; + } + + // Keep matching a valid hostname suffix after leading hyphens, as the URL regex does. if (labelStart !== rawLabelStart) { break; } @@ -468,7 +473,7 @@ function findUrlCandidates(text: string): UrlCandidate[] { } const tldEnd = findKnownTldEnd(text, index); - const hostnameStart = tldEnd === undefined ? undefined : findHostnameStart(text, index); + const hostnameStart = tldEnd === undefined ? undefined : findHostnameStart(text, index, tldEnd); if (tldEnd === undefined || hostnameStart === undefined) { index++; continue; diff --git a/lib/Url.js b/lib/Url.js index d2873f634..c7d2d486e 100644 --- a/lib/Url.js +++ b/lib/Url.js @@ -1,8 +1,14 @@ import TLD_REGEX from './tlds'; +import {MAX_URL_HOSTNAME_LABEL_LENGTH, MAX_URL_HOSTNAME_LENGTH} from './CONST'; const ALLOWED_PORTS = '([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])'; const URL_PROTOCOL_REGEX = '((ht|f)tps?:\\/\\/)'; -const URL_WEBSITE_REGEX = `${URL_PROTOCOL_REGEX}?((?:www\\.)?[a-z0-9](?=(?