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];