diff --git a/__tests__/ExpensiMark-HTML-test.js b/__tests__/ExpensiMark-HTML-test.js index 8d1e9a74..9fe70a1e 100644 --- a/__tests__/ExpensiMark-HTML-test.js +++ b/__tests__/ExpensiMark-HTML-test.js @@ -1259,8 +1259,8 @@ describe('Test long input candidate parsing', () => { ['an oversized label after a dot', 'google.com', `.${'b'.repeat(64)}`], ['an oversized label after a hyphen', 'example.com', `-${'b'.repeat(70)}`], ['an oversized hostname', `${'a.'.repeat(125)}com`, '.Thanks!'], - ])('preserves a known-TLD URL before %s', (_name, hostname, continuation) => { - // Given a valid URL whose adjacent text pushes a label or the complete hostname beyond a DNS limit. + ])('keeps the whole hostname plain with %s', (_name, hostname, continuation) => { + // Given a known TLD inside a hostname whose label or complete length exceeds a DNS limit. for (const protocol of ['', 'https://']) { const validUrl = `${protocol}${hostname}`; const input = `${validUrl}${continuation}`; @@ -1269,29 +1269,73 @@ describe('Test long input candidate parsing', () => { const result = parser.replace(input); const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); - // Then the valid URL stays linked without accepting the oversized continuation as part of its hostname. - expect(result).toBe(`${anchor(validUrl)}${continuation}`); - expect(rawHtmlResult).toBe(`x ${anchor(validUrl)}${continuation}`); + // Then neither path links a shorter hostname inside the invalid input. + expect(result).toBe(input); + expect(rawHtmlResult).toBe(`x ${input}`); } }); - test('keeps domains in an oversized continuation plain while linking a separate URL', () => { - // Given a valid maximum-length URL followed by an oversized continuation with a path and a separate domain. + test('keeps the entire oversized URL plain while linking a separate URL', () => { + // Given an oversized hostname with an internal known TLD, a domain in its path, and a separate URL. const validUrl = `https://${'a.'.repeat(125)}com`; const continuation = '.Thanks/path/example.org;'; const separateDomain = 'valid.net'; const input = `${validUrl}${continuation}${separateDomain}`; - // When both URL parsing paths recover the valid hostname prefix. + // When both URL parsing paths inspect the hostname and its connected path. const result = parser.replace(input); const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); - // Then only the valid prefix and separate domain link, so recovery cannot expose domains inside the invalid URL's path. - const expected = `${anchor(validUrl)}${continuation}${anchor(separateDomain)}`; + // Then only the separate domain links, leaving the invalid hostname and its path plain. + const expected = `${validUrl}${continuation}${anchor(separateDomain)}`; expect(result).toBe(expected); expect(rawHtmlResult).toBe(`x ${expected}`); }); + test.each([ + ['an oversized label', `foo.com.${'a'.repeat(64)}.net`], + ['an oversized hostname', `foo.com.${'a.'.repeat(122)}net`], + ])('does not recover an internal TLD from %s', (_name, hostname) => { + // Given a hostname with valid-looking domains on both sides of a DNS length violation. + for (const protocol of ['', 'https://', 'ftp://']) { + const input = `${protocol}${hostname}/path/example.org;valid.net`; + + // When candidate scanning, composer output, and raw HTML parse the invalid hostname. + const result = parser.replace(input); + const rawInputResult = parser.replace(input, {shouldKeepRawInput: true}); + const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); + + // Then only the separate URL links, without recovering a prefix or a domain from the connected path. + expect(result).toBe(`${protocol}${hostname}/path/example.org;${anchor('valid.net')}`); + expect(rawInputResult).toBe( + `${protocol}${hostname}/path/example.org;valid.net`, + ); + expect(rawHtmlResult).toBe(`x ${result}`); + } + }); + + test.each([ + ['an oversized domain label', 'foo.bar', `${'a'.repeat(64)}.com`], + ['an oversized domain', 'foo.bar', `aa.${'a.'.repeat(124)}com`], + ['another known TLD in the local part', 'www.foo.bar', `${'a'.repeat(64)}.com`], + ])('keeps email context visible with %s', (_name, localPart, domain) => { + // Given an invalid email whose local part looks like a URL, with separate valid URLs on both sides. + const input = `valid.net ${localPart}@${domain} valid.org`; + const expected = `${anchor('valid.net')} ${localPart}@${domain} ${anchor('valid.org')}`; + + // When the complete parser applies URL and mention rules in the composer and alongside raw HTML. + const result = parser.replace(input); + const rawInputResult = parser.replace(input, {shouldKeepRawInput: true}); + const rawHtmlResult = parser.replace(`x ${input}`, {shouldEscapeText: false}); + + // Then the whole invalid email stays plain, without turning its local part into a URL or its domain into a mention. + expect(result).toBe(expected); + expect(rawInputResult).toBe( + `valid.net ${localPart}@${domain} valid.org`, + ); + expect(rawHtmlResult).toBe(`x ${expected}`); + }); + test('does not recover URL prefixes inside raw HTML attributes or protected elements', () => { // Given an oversized continuation inside an anchor attribute, protected code, and a span rejected by the HTML lookaheads. const inputUrl = `https://${'a.'.repeat(125)}com.Thanks!`; diff --git a/lib/ExpensiMark.ts b/lib/ExpensiMark.ts index f82bb872..f43e3a2e 100644 --- a/lib/ExpensiMark.ts +++ b/lib/ExpensiMark.ts @@ -348,14 +348,14 @@ function findKnownTldEnd(text: string, dotPosition: number): number | undefined } /** - * Validates a hostname and records a linkable prefix before a later malformed label. - * Preserves known-TLD prefixes before oversized continuations without recovering text inside an invalid leading label. + * Validates a hostname and records a linkable prefix before malformed punctuation in a later label. + * Rejects the whole hostname when a label or its complete length exceeds a DNS limit. * * @param text - Text containing the URL candidate. * @param hostnameStart - Index of the hostname's first character. * @param hostnameEnd - Index immediately after the hostname. * @param hasProtocol - Whether the URL allows hostnames without a known TLD, including single-label hosts. - * @returns The hostname validity and any prefix that can be linked before a malformed or oversized continuation. + * @returns The hostname validity and any prefix that can be linked before malformed punctuation. */ function analyzeHostname(text: string, hostnameStart: number, hostnameEnd: number, hasProtocol: boolean): HostnameAnalysis { let hostnameContentEnd = hostnameEnd; @@ -369,7 +369,6 @@ function analyzeHostname(text: string, hostnameStart: number, hostnameEnd: numbe let labelStart = hostnameStart; let validHostnamePrefixEnd: number | undefined; - let validKnownTldPrefixEnd: number | undefined; for (let index = hostnameStart; index <= hostnameContentEnd; index++) { if (index !== hostnameContentEnd && text[index] !== '.') { continue; @@ -386,7 +385,7 @@ function analyzeHostname(text: string, hostnameStart: number, hostnameEnd: numbe labelContentEnd--; } if (labelContentEnd - labelStart > Constants.MAX_URL_HOSTNAME_LABEL_LENGTH || labelContentEnd - hostnameStart > Constants.MAX_URL_HOSTNAME_LENGTH) { - return {isValid: false, validHostnamePrefixEnd: validKnownTldPrefixEnd}; + return {isValid: false}; } // Protocol URLs can end at any valid label; bare URLs still need a known TLD. if (hasProtocol) { @@ -396,13 +395,10 @@ function analyzeHostname(text: string, hostnameStart: number, hostnameEnd: numbe return {isValid: false, validHostnamePrefixEnd}; } - if (index !== hostnameContentEnd) { + if (!hasProtocol && index !== hostnameContentEnd) { const tldEnd = findKnownTldEnd(text, index); if (tldEnd !== undefined && tldEnd <= hostnameContentEnd && tldEnd - hostnameStart <= Constants.MAX_URL_HOSTNAME_LENGTH) { - validKnownTldPrefixEnd = tldEnd; - if (!hasProtocol) { - validHostnamePrefixEnd = tldEnd; - } + validHostnamePrefixEnd = tldEnd; } } labelStart = index + 1; @@ -2055,21 +2051,20 @@ export default class ExpensiMark { const invalidUrlRanges = shouldRejectPartialHostnameMatches ? findInvalidUrlRanges(textWithContext) : []; const maskedText = invalidUrlRanges.length > 0 ? maskInvalidUrlRanges(textWithContext, invalidUrlRanges) : textWithContext; const textToMatch = maskedText.slice(0, textToCheck.length); - const contextRegex = followingText ? new RegExp(regex.source, regex.flags.replace('g', 'y')) : undefined; + const contextRegex = followingText || invalidUrlRanges.length > 0 ? new RegExp(regex.source, regex.flags.replace('g', 'y')) : undefined; let match = matchingRegex.exec(textToMatch); let replacedText = ''; let startIndex = 0; while (match !== null) { - if (contextRegex && match.index + match[0].length === textToCheck.length) { - // Recheck only this match with its following text, without searching outside the candidate. + const matchEnd = match.index + match[0].length; + if (contextRegex && (textWithContext[matchEnd] === '@' || (followingText.length > 0 && matchEnd === textToCheck.length))) { + // Check the original context so masking cannot turn an email's local part into a URL. contextRegex.lastIndex = match.index; - const contextMatch = contextRegex.exec(maskedText); - if (contextMatch === null) { + const contextMatch = contextRegex.exec(textWithContext); + if (contextMatch === null || contextMatch[0].length < match[0].length) { match = matchingRegex.exec(textToMatch); continue; } - match = contextMatch; - matchingRegex.lastIndex = contextRegex.lastIndex; } let url = match[2];