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
1 change: 1 addition & 0 deletions packages/tasks/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export * from "./task/vector/VectorNormalizeTask";
export * from "./task/vector/VectorScaleTask";
export * from "./task/vector/VectorSubtractTask";
export * from "./task/vector/VectorSumTask";
export * from "./util/BoundedRegexRunner";
export * from "./util/regexSafety";
export * from "./util/SafeFetch";
export * from "./util/UrlClassifier";
Expand Down
1 change: 1 addition & 0 deletions packages/tasks/src/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import "./codec.node";
import "./task/image/registerImageTextRenderer.node";
import "./util/SafeFetch.server";
import "./util/BoundedRegex.server";

export * from "./common";
export * from "./task/FileGrepTask.server";
Expand Down
1 change: 1 addition & 0 deletions packages/tasks/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import "./task/image/registerImageTextRenderer.node";
// Install the DNS-resolving, connection-pinning SafeFetch implementation.
// This side-effect import must happen before FetchUrlTask is used.
import "./util/SafeFetch.server";
import "./util/BoundedRegex.server";

export * from "./common";
export * from "./task/FileGrepTask.server";
Expand Down
23 changes: 8 additions & 15 deletions packages/tasks/src/task/RegexTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,28 @@
import type { IExecuteContext, IExecutePreviewContext, TaskConfig } from "@workglow/task-graph";
import { CreateWorkflow, Task, Workflow } from "@workglow/task-graph";
import type { DataPortSchema, FromSchema } from "@workglow/util/schema";
import { assertSafeRegexPattern } from "../util/regexSafety";
import { getRegexRunnerFactory } from "../util/BoundedRegexRunner";
import { compileSafeRegex } from "../util/regexSafety";

function executeRegex(input: { value: string; pattern: string; flags?: string }): {
match: boolean;
matches: string[];
} {
assertSafeRegexPattern(input.pattern);

const flags = input.flags ?? "";
const regex = new RegExp(input.pattern, flags);
const runner = getRegexRunnerFactory()(compileSafeRegex(input.pattern, flags));

if (flags.includes("g")) {
const allMatches = Array.from(input.value.matchAll(new RegExp(input.pattern, flags)));
return {
match: allMatches.length > 0,
matches: allMatches.map((m) => m[0]),
};
const matches = runner.execAll(input.value);
return { match: matches.length > 0, matches };
}

const result = regex.exec(input.value);
if (!result) {
const result = runner.exec(input.value);
if (result === undefined) {
return { match: false, matches: [] as string[] };
}

// Return full match + captured groups
return {
match: true,
matches: result.slice(0),
};
return { match: true, matches: result as string[] };
}

const inputSchema = {
Expand Down
95 changes: 95 additions & 0 deletions packages/tasks/src/util/BoundedRegex.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
*/

import { TaskInvalidInputError } from "@workglow/task-graph";
import { SECURITY_LIMITS } from "@workglow/util";
import { createContext, Script } from "node:vm";
import type { RegexRunnerFactory } from "./BoundedRegexRunner";
import { registerRegexRunnerFactory } from "./BoundedRegexRunner";

/**
* Matches lines against a regex under an interruptible wall-clock budget.
Expand Down Expand Up @@ -221,3 +224,95 @@ export function createBoundedRegexReplacer(
return { texts: [...scope.out], counts: [...scope.counts] };
};
}

/**
* One value, one regex — the shape {@link RegexTask} needs, where the batching
* the other helpers do has nothing to amortize over.
*
* The context and script are module-level and shared across every runner: a
* fresh `createContext` per call site costs ~0.74 ms against ~0.16 ms for a
* reused one, and a task that evaluates a single value per run pays that on
* every run. `re` and `value` are assigned per call instead.
*/
const execContext = createContext({
re: undefined as RegExp | undefined,
value: "",
all: false,
matched: false,
out: [] as (string | undefined)[],
});

// Wrapped so `result` is not redeclared in the context's global scope on the
// second call — same reason as {@link createBoundedRegexExtractor}.
const execScript = new Script(`(function () {
out.length = 0;
matched = false;
re.lastIndex = 0;
if (all) {
let result;
while ((result = re.exec(value)) !== null) {
if (result[0].length === 0) {
re.lastIndex++;
continue;
}
out.push(result[0]);
if (!re.global) break;
}
matched = out.length > 0;
} else {
const result = re.exec(value);
if (result !== null) {
matched = true;
const parts = Array.prototype.slice.call(result);
for (let i = 0; i < parts.length; i++) out.push(parts[i]);
}
}
})();`);

interface ExecScope {
re: RegExp;
value: string;
all: boolean;
matched: boolean;
out: (string | undefined)[];
}

/**
* Builds a {@link RegexRunnerFactory} that runs each match under an
* interruptible wall-clock budget, for the same reason and with the same
* residual as {@link createBoundedRegexMatcher}.
*/
export function createBoundedRegexExecutor(timeoutMs: number): RegexRunnerFactory {
return (regex) => {
const scope = execContext as unknown as ExecScope;

const run = (value: string, all: boolean): void => {
scope.re = regex;
scope.value = value;
scope.all = all;
try {
execScript.runInContext(execContext, { timeout: timeoutMs });
} catch {
// Deliberately not a TaskTimeoutError: that extends TaskAbortedError and
// would report the run as aborted rather than failed by bad input.
throw new TaskInvalidInputError(
`Regex matching exceeded its ${timeoutMs}ms budget for pattern /${regex.source}/ — ` +
`the pattern backtracks catastrophically on this input. Simplify the pattern.`
);
}
};

return {
exec: (value) => {
run(value, false);
return scope.matched ? [...scope.out] : undefined;
},
execAll: (value) => {
run(value, true);
return [...scope.out] as string[];
},
};
};
}

registerRegexRunnerFactory(createBoundedRegexExecutor(SECURITY_LIMITS.regexMatchBatchTimeoutMs));
81 changes: 81 additions & 0 deletions packages/tasks/src/util/BoundedRegexRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* Seam for running a single regex against a single value.
*
* The shape screen in `regexSafety.ts` is a heuristic, not a decision
* procedure — `^(a?b?)*$` passes it and still backtracks catastrophically. What
* contains that is a wall-clock budget at match time, which needs a `vm` and so
* cannot live in the browser build. The Node/Bun/Electron entrypoints register
* the bounded implementation (see `BoundedRegex.server.ts`); the browser default
* matches unbounded, where a hostile pattern blocks that tab rather than the
* process hosting a local API.
*/

/** Runs one compiled regex against one value. */
export interface RegexRunner {
/**
* The full match followed by its capture groups, or `undefined` when the
* pattern did not match. An unmatched optional group stays `undefined` in
* place rather than being dropped, so group indices keep their meaning.
*/
readonly exec: (value: string) => (string | undefined)[] | undefined;
/** Every non-empty full match, for a regex carrying the `g` flag. */
readonly execAll: (value: string) => string[];
}

export type RegexRunnerFactory = (regex: RegExp) => RegexRunner;

/** Advances past a zero-length match so the scan cannot stall on it. */
function execAllUnbounded(regex: RegExp, value: string): string[] {
const matches: string[] = [];
regex.lastIndex = 0;
let result = regex.exec(value);
while (result !== null) {
if (result[0].length === 0) {
regex.lastIndex++;
} else {
matches.push(result[0]);
}
if (!regex.global) break;
result = regex.exec(value);
}
return matches;
}

/** Plain, unbounded matching on the calling thread. */
export const defaultRegexRunnerFactory: RegexRunnerFactory = (regex) => ({
exec: (value) => {
regex.lastIndex = 0;
const result = regex.exec(value);
return result === null ? undefined : (result.slice(0) as (string | undefined)[]);
},
execAll: (value) => execAllUnbounded(regex, value),
});

let currentFactory: RegexRunnerFactory = defaultRegexRunnerFactory;

/**
* Register a platform-specific runner factory. The Node/Bun entrypoints call
* this at module load time to install the budgeted implementation from
* `BoundedRegex.server.ts`.
*
* Returns the previously registered factory so callers can safely restore it
* after a temporary override.
*/
export function registerRegexRunnerFactory(fn: RegexRunnerFactory): RegexRunnerFactory {
const previousFactory = currentFactory;
currentFactory = fn;
return previousFactory;
}

export function getRegexRunnerFactory(): RegexRunnerFactory {
return currentFactory;
}

/** Restores the default unbounded implementation. */
export function resetRegexRunnerFactory(): void {
currentFactory = defaultRegexRunnerFactory;
}
29 changes: 25 additions & 4 deletions packages/tasks/src/util/regexSafety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@ function isUnboundedRepetitionAt(pattern: string, index: number): boolean {
return UNBOUNDED_REPETITION_RE.test(pattern);
}

/** `{n}` / `{n,}` / `{n,m}` starting exactly at an index, sticky for the same reason. */
const COUNTED_REPETITION_RE = /\{(\d+)(?:,(\d*))?\}/y;

/**
* True when the quantifier at `index` permits TWO OR MORE repetitions, which is
* what turns a quantifying group body into a backtracking blow-up.
*
* `?` and `{0,1}` do not: they bound the group to at most one repetition, so
* `(\.\d+)?` stays linear no matter what its body quantifies. `{10}` does, even
* though it is bounded — `(a+){10}` is measurably catastrophic.
*/
function quantifierAllowsRepeat(pattern: string, index: number): boolean {
const char = pattern[index];
if (char === "*" || char === "+") return true;
if (char !== "{") return false;
COUNTED_REPETITION_RE.lastIndex = index;
const counted = COUNTED_REPETITION_RE.exec(pattern);
if (counted === null) return false;
const [, min, max] = counted;
if (max === undefined) return Number(min) >= 2;
if (max === "") return true;
return Number(max) >= 2;
}

interface PatternScan {
/** Every `[` in the source, including literals inside a class. */
readonly bracketCount: number;
Expand Down Expand Up @@ -80,10 +104,7 @@ function scanPattern(pattern: string): PatternScan {
continue;
}

const next = pattern[index + 1];
const groupIsQuantified = next === "*" || next === "+" || next === "?" || next === "{";

if (bodyQuantifies && groupIsQuantified) {
if (bodyQuantifies && quantifierAllowsRepeat(pattern, index + 1)) {
nestedQuantifiers = true;
}

Expand Down
65 changes: 64 additions & 1 deletion packages/test/src/test/task/RegexSafety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("assertSafeRegexPattern", () => {
});

it("rejects nested quantifiers", () => {
for (const pattern of ["(a+)+", "(a*)+", "(a+)*", "(a+)?", "(a+){2}", "((b|c+))+"]) {
for (const pattern of ["(a+)+", "(a*)+", "(a+)*", "(a+){2}", "((b|c+))+"]) {
expect(() => assertSafeRegexPattern(pattern)).toThrow(/nested quantifiers/);
}
});
Expand Down Expand Up @@ -140,4 +140,67 @@ describe("hasUnsafeRegexShape", () => {
it("still catches a class branch that can match empty", () => {
expect(hasUnsafeRegexShape("([a-z]*|[A-Z])+")).toBe(true);
});

/**
* A group made optional is bounded to at most ONE repetition, so its body's
* own `+` has nothing to backtrack against — `(X)?` is linear whatever `X`
* quantifies. Reading `?` as "the group is quantified" rejected the most
* ordinary shapes there are: an optional decimal part, an optional comment
* line, an optional trailing capture.
*/
it.each([
"^-?\\d+(\\.\\d+)?$",
"^-?\\d+(?:\\.\\d+)?$",
"(\\w+)?",
"^(#.*)?$",
"(\\s+)?end",
"(https?://\\S+)?",
"(ERROR|WARN)\\s+(\\w+)?",
"(a+)?",
"(?<year>\\d+)?",
"(a+){1}",
"(a+){0,1}",
"(a+){foo}",
"(?:\\r?\\n)+",
"^(?:https?://)?(www\\.)?example\\.com",
])("accepts the optional group %s", (pattern) => {
expect(hasUnsafeRegexShape(pattern)).toBe(false);
});

/**
* The rule is "does the quantifier permit two or more repetitions", NOT "is
* it a real `{n,}`/`{n,m}`". The cheaper-looking variant — reuse the existing
* comma-requiring `isUnboundedRepetitionAt` — would accept every one of
* these; `(a+){10}` measured 46 318 ms against a 41-character input.
*/
it.each(["(a+){2}", "(a+){2,3}", "(a+){10}", "(a+){2,}"])(
"still rejects the counted repetition %s",
(pattern) => {
expect(hasUnsafeRegexShape(pattern)).toBe(true);
}
);

/**
* The inner `(a+)?` no longer trips the rule on its own, but a group whose
* body quantifies still counts as a quantifier for whatever encloses it — so
* the outer `+` over it is caught exactly as before.
*/
it("still rejects an optional quantifying group under an outer quantifier", () => {
expect(hasUnsafeRegexShape("((a+)?)+")).toBe(true);
});

it("stays linear on a newly accepted optional group", () => {
// The screen is what changed, so compile exactly what it now accepts.
const pattern = "^-?\\d+(\\.\\d+)?$";
expect(hasUnsafeRegexShape(pattern)).toBe(false);
const regex = new RegExp(pattern);
expect(regex.exec("-12.5")?.[1]).toBe(".5");

// The optional group bounds itself to one repetition, so a long non-match
// is a single failed scan rather than an exponential search.
const value = "1".repeat(200_000) + "!";
const started = performance.now();
expect(regex.test(value)).toBe(false);
expect(performance.now() - started).toBeLessThan(200);
});
});
Loading
Loading