Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 54 additions & 10 deletions __tests__/ExpensiMark-HTML-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -1269,29 +1269,73 @@ describe('Test long input candidate parsing', () => {
const result = parser.replace(input);
const rawHtmlResult = parser.replace(`<span>x</span> ${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(`<span>x</span> ${anchor(validUrl)}${continuation}`);
// Then neither path links a shorter hostname inside the invalid input.
expect(result).toBe(input);
expect(rawHtmlResult).toBe(`<span>x</span> ${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(`<span>x</span> ${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(`<span>x</span> ${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(`<span>x</span> ${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;<a href="https://valid.net" data-raw-href="valid.net" data-link-variant="auto" target="_blank" rel="noreferrer noopener">valid.net</a>`,
);
expect(rawHtmlResult).toBe(`<span>x</span> ${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(`<span>x</span> ${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(
`<a href="https://valid.net" data-raw-href="valid.net" data-link-variant="auto" target="_blank" rel="noreferrer noopener">valid.net</a> ${localPart}@${domain} <a href="https://valid.org" data-raw-href="valid.org" data-link-variant="auto" target="_blank" rel="noreferrer noopener">valid.org</a>`,
);
expect(rawHtmlResult).toBe(`<span>x</span> ${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!`;
Expand Down
29 changes: 12 additions & 17 deletions lib/ExpensiMark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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];
Expand Down
Loading