Bound the email parts in the autolinker regex

The local part and the domain labels were unbounded, making the
search quadratic in the length of a run of characters preceding
an "@": scanning the text of a single page could take seconds.
This commit is contained in:
calixteman 2026-08-01 15:46:46 +02:00
parent 7fc7072f9c
commit b1f51818ae
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
2 changed files with 25 additions and 2 deletions

View File

@ -220,4 +220,24 @@ describe("autolinker", function () {
["john.doe@uni-cityname.tld", "mailto:john.doe@uni-cityname.tld"], ["john.doe@uni-cityname.tld", "mailto:john.doe@uni-cityname.tld"],
]); ]);
}); });
it("should find emails with the longest parts allowed by the RFCs", function () {
const local = "a".repeat(64);
const label = "b".repeat(63);
testLinks([[`${local}@${label}.com`, `mailto:${local}@${label}.com`]]);
});
it("shouldn't find emails with parts longer than allowed by the RFCs", function () {
expect(
Autolinker.findLinks(`${"a".repeat(107)}@${"a".repeat(80)}.com`)
).toEqual([]);
});
it("should handle a long run of characters before an @ efficiently", function () {
const text = `${"a".repeat(50000)}@`;
const startTime = performance.now();
expect(Autolinker.findLinks(text)).toEqual([]);
expect(performance.now() - startTime).toBeLessThan(1000);
});
}); });

View File

@ -136,10 +136,13 @@ class Autolinker {
static #numericTLDRegex; static #numericTLDRegex;
static findLinks(text) { static findLinks(text) {
// Regex can be tested and verified at https://regex101.com/r/rXoLiT/2. // Regex can be tested and verified at https://regex101.com/r/riHjvK/1.
// The email parts are bounded to keep the scan linear: a local part can't
// exceed 64 characters (RFC 5321, section 4.5.3.1.1) and a domain label
// can't exceed 63 (RFC 1035, section 2.3.4).
this.#regex ??= this.#regex ??=
// eslint-disable-next-line regexp/no-super-linear-backtracking // eslint-disable-next-line regexp/no-super-linear-backtracking
/\b(?:https?:\/\/|mailto:|www\.)(?:[\S--[\p{P}<>]]|\/|[\S--[\[\]]]+[\S--[\p{P}<>]])+|(?=\p{L})[\S--[@\p{Ps}\p{Pe}<>]]+@([\S--[[\p{P}--\-]<>]]+(?:\.[\S--[[\p{P}--\-]<>]]+)+)/gv; /\b(?:https?:\/\/|mailto:|www\.)(?:[\S--[\p{P}<>]]|\/|[\S--[\[\]]]+[\S--[\p{P}<>]])+|(?=\p{L})[\S--[@\p{Ps}\p{Pe}<>]]{1,64}@([\S--[[\p{P}--\-]<>]]{1,63}(?:\.[\S--[[\p{P}--\-]<>]]{1,63})+)/gv;
const [normalizedText, diffs] = normalize(text, { ignoreDashEOL: true }); const [normalizedText, diffs] = normalize(text, { ignoreDashEOL: true });
const matches = normalizedText.matchAll(this.#regex); const matches = normalizedText.matchAll(this.#regex);