Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
00fe03d
fix(expensimark): enforce DNS hostname length limits
Uzaifm127 Oct 1, 2026
6926110
fix(types): update URL declarations for hostname validation
Uzaifm127 Oct 2, 2026
10ed587
fix(expensimark): prevent partial links in invalid hostnames
Uzaifm127 Oct 3, 2026
cec44f6
perf(expensimark): reject oversized protocol URLs early
Uzaifm127 Oct 3, 2026
9db6317
fix(expensimark): parse valid URLs after invalid protocol URLs
Uzaifm127 Oct 3, 2026
e775405
test: add a unit test to verify oversized URLs skip regex matching
Uzaifm127 Oct 3, 2026
0427feb
fix(expensimark): reject partial hostname links when shouldEscapeText…
Uzaifm127 Oct 3, 2026
b646330
fix(expensimark): prevent partial autolinks in oversized URLs with pa…
Uzaifm127 Oct 4, 2026
ef56b7a
fix(prettier): resolve ExpensiMark formatting check
Uzaifm127 Oct 4, 2026
b2841a9
fix(expensimark): stop partially linking URLs with oversized hostnames
Uzaifm127 Oct 5, 2026
dba5211
refactor(expensimark): centralize hostname length checks for URL cand…
Uzaifm127 Oct 5, 2026
ba70bc8
fix(expensimark): keep invalid URL ranges from producing partial links
Uzaifm127 Oct 5, 2026
47b8760
refactor(expensimark): remove duplicate hostname scan from URL candid…
Uzaifm127 Oct 5, 2026
d2d37b8
refactor(expensimark): reuse the bare domain candidate after validation
Uzaifm127 Oct 5, 2026
19b3521
chore: clarify comments for invalid URL range checks
Uzaifm127 Oct 5, 2026
9fa5676
fix(expensimark): keep valid URL prefixes linked when hostname parsin…
Uzaifm127 Oct 9, 2026
908cc87
perf(expensimark): match URL suffixes without rechecking hostnames
Uzaifm127 Oct 9, 2026
5ed4659
fix(expensimark): include valid ports when measuring invalid hostname…
Uzaifm127 Oct 9, 2026
e661ba0
refactor(expensimark): reuse URL hostname label regex in the candidat…
Uzaifm127 Oct 11, 2026
c4aecfb
test(expensimark): remove duplicate URL test cases
Uzaifm127 Oct 11, 2026
be51271
fix(expensimark): keep valid protocol host labels linked before malfo…
Uzaifm127 Oct 12, 2026
39fbf44
fix(expensimark): keep valid URL prefixes linked after malformed host…
Uzaifm127 Oct 12, 2026
111ae2e
fix(expensimark): stop invalid URL ranges from swallowing the next pr…
Uzaifm127 Oct 12, 2026
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
504 changes: 494 additions & 10 deletions __tests__/ExpensiMark-HTML-test.js

Large diffs are not rendered by default.

182 changes: 181 additions & 1 deletion __tests__/URL-test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {URL_REGEX_WITH_REQUIRED_PROTOCOL, URL_REGEX, LOOSE_URL_REGEX} from '../lib/Url';
import fs from 'node:fs';
import * as UrlPatterns from '../lib/Url';

const {URL_REGEX_WITH_REQUIRED_PROTOCOL, URL_REGEX, LOOSE_URL_REGEX, MARKDOWN_URL_REGEX} = UrlPatterns;

describe('Strict URL validation', () => {
describe('Mandatory protocol for URL', () => {
Expand Down Expand Up @@ -65,3 +68,180 @@ 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, ''],
['LOOSE_URL_REGEX', LOOSE_URL_REGEX, 'https://'],
])('%s preserves valid prefixes before long malformed hostname continuations', (_name, pattern, protocol) => {
// Given valid hostnames followed by malformed punctuation whose text would exceed a DNS length limit.
const regexToTest = new RegExp(pattern, 'i');
const validUrl = `${protocol}example.com`;
const maximumLengthUrl = `${protocol}${'a.'.repeat(125)}com`;
const inputs = [`${validUrl}..${'a'.repeat(300)}`, `${validUrl}.-${'a'.repeat(63)}`, `${validUrl}-.${'a'.repeat(300)}`, `${maximumLengthUrl}..nonsense`];

// When URL matching reaches malformed punctuation after the valid hostname.
const matches = inputs.map((input) => {
const match = regexToTest.exec(input);
return match && match[0];
});

// Then only the actual hostname counts toward the limits, so each valid prefix still matches.
expect(matches).toEqual([validUrl, validUrl, validUrl, maximumLengthUrl]);
});

it.each([
['URL_REGEX', URL_REGEX, ''],
['LOOSE_URL_REGEX', LOOSE_URL_REGEX, 'https://'],
])('%s rejects oversized hostnames even before malformed continuations', (_name, pattern, protocol) => {
// Given an oversized hostname containing a known-TLD prefix, followed by malformed punctuation.
const input = `${protocol}example.com${'.a'.repeat(122)}..nonsense`;
const regexToTest = new RegExp(pattern, 'i');

// When URL matching checks the hostname before the malformed continuation.
const match = regexToTest.exec(input);

// Then the whole oversized hostname remains rejected rather than matching its earlier valid-looking prefix.
expect(match).toBeNull();
});

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.each([
['an invalid dot-separated label', `${'a'.repeat(64)}.example.com`],
['an invalid hyphenated label after a protocol', `https://${'a'.repeat(64)}-valid.com`],
['a leading hyphen attached to another hostname label', 'foo.-valid.com'],
['multiple leading hyphens', '--valid.com'],
])('does not match a valid hostname suffix inside %s', (_name, invalidUrl) => {
// Given an invalid hostname that contains a shorter valid-looking domain suffix.
const regexToTest = new RegExp(MARKDOWN_URL_REGEX, 'i');

// When the URL pattern searches the complete invalid value.
const match = regexToTest.exec(invalidUrl);

// Then it rejects the whole value instead of matching only the valid suffix.
expect(match).toBeNull();
});

it('keeps matching a domain after a standalone boundary hyphen', () => {
// Given a valid domain preceded by one standalone hyphen used as punctuation.
const input = '-example.com';
const regexToTest = new RegExp(MARKDOWN_URL_REGEX, 'i');

// When the URL pattern searches the complete text.
const match = regexToTest.exec(input);

// Then it links the valid domain after the boundary without including the hyphen.
expect(match && match[0]).toBe('example.com');
});

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();
});
});

describe('URL pattern consistency', () => {
it.each([
['a one-character label', 'a', true],
['a hyphen inside a label', 'valid-label', true],
['a leading hyphen', '-invalid', false],
['a trailing hyphen', 'invalid-', false],
['an underscore', 'invalid_label', false],
])('keeps strict and loose URL patterns aligned for %s', (_name, label, shouldMatch) => {
// Given the same hostname label used by strict and loose URL patterns.
const labelRegex = new RegExp(`^${UrlPatterns.URL_HOSTNAME_LABEL_REGEX}$`, 'i');
const strictUrlRegex = new RegExp(`^${URL_REGEX}$`, 'i');
const looseUrlRegex = new RegExp(`^${LOOSE_URL_REGEX}$`, 'i');

// When each URL pattern validates the label.
const results = [labelRegex.test(label), strictUrlRegex.test(`${label}.com`), looseUrlRegex.test(`https://${label}.local`)];

// Then every pattern applies the same label syntax and length limits.
expect(results).toEqual([shouldMatch, shouldMatch, shouldMatch]);
});

it('keeps published URL pattern declarations aligned with their runtime values', () => {
// Given the URL patterns exported at runtime and their published literal declarations.
const declaration = fs.readFileSync('lib/Url.d.ts', 'utf8');
const literalPatternNames = [
'URL_PROTOCOL_REGEX',
'URL_PORT_REGEX',
'URL_HOSTNAME_LABEL_REGEX',
'URL_WEBSITE_REGEX',
'URL_PATH_REGEX',
'URL_PARAM_REGEX',
'URL_FRAGMENT_REGEX',
'URL_REGEX',
'LOOSE_URL_WEBSITE_REGEX',
'LOOSE_URL_REGEX',
'MARKDOWN_URL_REGEX',
];

// When each runtime value is escaped as the matching TypeScript string literal.
const staleDeclarations = literalPatternNames.filter((name) => {
const escapedPattern = UrlPatterns[name].replaceAll('\\', '\\\\').replaceAll("'", "\\'");
return !declaration.includes(`declare const ${name}: '${escapedPattern}';`);
});

// Then Url.d.ts describes the exact patterns exported by Url.js.
expect(staleDeclarations).toEqual([]);
});
});
8 changes: 7 additions & 1 deletion lib/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))(?<end>.*))\\S{3,254}(?=\\k<end>$)";

// 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';

/**
Expand Down Expand Up @@ -1940,4 +1946,4 @@ const PUBLIC_DOMAINS_SET = new Set<string>([
'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};
Loading
Loading