Files
homarr/packages/custom-widgets/src/jsx/regex-policy.ts
T

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