145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
interface RegexGroupState {
|
|
atomCount: number;
|
|
containsAlternation: boolean;
|
|
containsQuantifier: boolean;
|
|
}
|
|
|
|
interface RegexQuantifier {
|
|
nextIndex: number;
|
|
variable: boolean;
|
|
}
|
|
|
|
const MAX_REGEX_PATTERN_LENGTH = 128;
|
|
const MAX_EXACT_REPETITION = 1_000;
|
|
|
|
/**
|
|
* JavaScript's regular-expression engine can backtrack exponentially. Keep authored
|
|
* expressions in a deliberately small, linear-time subset: no lookarounds or
|
|
* backreferences, no repeated alternatives/groups, and at most one quantifier that
|
|
* can consume a variable number of characters in the whole expression.
|
|
*/
|
|
export function isSafeRegexLiteral(regex: unknown): boolean {
|
|
if (!regex || typeof regex !== "object") return false;
|
|
const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
|
|
if (
|
|
typeof pattern !== "string" ||
|
|
pattern.length > MAX_REGEX_PATTERN_LENGTH ||
|
|
typeof flags !== "string" ||
|
|
/[^gimsu]/u.test(flags)
|
|
) {
|
|
return false;
|
|
}
|
|
return isSafeRegexPattern(pattern);
|
|
}
|
|
|
|
function isSafeRegexPattern(pattern: string): boolean {
|
|
const groups: RegexGroupState[] = [createGroupState()];
|
|
let variableQuantifiers = 0;
|
|
let index = 0;
|
|
|
|
const consumeAtom = (nested?: RegexGroupState): boolean => {
|
|
const parent = groups.at(-1);
|
|
if (!parent) return false;
|
|
parent.atomCount += 1;
|
|
if (nested) {
|
|
parent.containsAlternation ||= nested.containsAlternation;
|
|
parent.containsQuantifier ||= nested.containsQuantifier;
|
|
}
|
|
const quantifier = readQuantifier(pattern, index);
|
|
if (!quantifier) return true;
|
|
if (quantifier.nextIndex > pattern.length) return false;
|
|
if (nested && (nested.atomCount === 0 || nested.containsAlternation || nested.containsQuantifier)) return false;
|
|
parent.containsQuantifier = true;
|
|
if (quantifier.variable && ++variableQuantifiers > 1) return false;
|
|
index = quantifier.nextIndex;
|
|
return true;
|
|
};
|
|
|
|
while (index < pattern.length) {
|
|
const character = pattern[index];
|
|
if (character === "\\") {
|
|
const escaped = pattern[index + 1];
|
|
if (escaped === undefined || /[1-9k]/u.test(escaped)) return false;
|
|
index += 2;
|
|
if (!consumeAtom()) return false;
|
|
continue;
|
|
}
|
|
if (character === "[") {
|
|
index += 1;
|
|
let closed = false;
|
|
while (index < pattern.length) {
|
|
if (pattern[index] === "\\") index += 2;
|
|
else {
|
|
const classCharacter = pattern[index++];
|
|
if (classCharacter === "]") {
|
|
closed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!closed || !consumeAtom()) return false;
|
|
continue;
|
|
}
|
|
if (character === "(") {
|
|
if (pattern[index + 1] === "?") return false;
|
|
groups.push(createGroupState());
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (character === ")") {
|
|
if (groups.length === 1) return false;
|
|
const nested = groups.pop();
|
|
index += 1;
|
|
if (!nested || !consumeAtom(nested)) return false;
|
|
continue;
|
|
}
|
|
if (character === "|") {
|
|
const current = groups.at(-1);
|
|
if (!current) return false;
|
|
current.containsAlternation = true;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (character === "^" || character === "$") {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (character === "*" || character === "+" || character === "?" || character === "{") {
|
|
// A valid quantifier is consumed with its atom. Reaching one here means it is
|
|
// malformed or is an unsupported literal brace construct.
|
|
return false;
|
|
}
|
|
index += 1;
|
|
if (!consumeAtom()) return false;
|
|
}
|
|
|
|
return groups.length === 1;
|
|
}
|
|
|
|
function createGroupState(): RegexGroupState {
|
|
return { atomCount: 0, containsAlternation: false, containsQuantifier: false };
|
|
}
|
|
|
|
function readQuantifier(pattern: string, index: number): RegexQuantifier | null {
|
|
const character = pattern[index];
|
|
if (character === "*" || character === "+" || character === "?") {
|
|
const nextIndex = pattern[index + 1] === "?" ? index + 2 : index + 1;
|
|
return { nextIndex, variable: true };
|
|
}
|
|
if (character !== "{") return null;
|
|
const match = /^\{(\d+)(?:,(\d*))?\}/u.exec(pattern.slice(index));
|
|
if (!match) return null;
|
|
const minimum = Number(match[1]);
|
|
const maximum = match[2] === undefined || match[2] === "" ? undefined : Number(match[2]);
|
|
if (
|
|
!Number.isSafeInteger(minimum) ||
|
|
minimum > MAX_EXACT_REPETITION ||
|
|
(maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum > MAX_EXACT_REPETITION))
|
|
) {
|
|
return { nextIndex: pattern.length + 1, variable: true };
|
|
}
|
|
let nextIndex = index + match[0].length;
|
|
if (pattern[nextIndex] === "?") nextIndex += 1;
|
|
return { nextIndex, variable: match[2] !== undefined && maximum !== minimum };
|
|
}
|