diff --git a/CHANGELOG.md b/CHANGELOG.md index a51889d..8d1ae02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Security and Correctness + +- Replace backtracking-prone path-segment, temp-name, and Windows device-path sanitizers with linear scans to keep attacker-controlled inputs from causing excessive CPU use. + ### Docs and Tooling - Publish only the validated release tarball after asserting its byte identity against the release manifest, and tolerate npm registry propagation with bounded exponential backoff. diff --git a/src/device-path.ts b/src/device-path.ts index 2bf572b..f700684 100644 --- a/src/device-path.ts +++ b/src/device-path.ts @@ -63,6 +63,33 @@ const WINDOWS_RESERVED_DEVICE_NAMES = new Set([ "LPT³", ]); +const WINDOWS_SEPARATOR_CHAR_CODE = 0x5c; +const WINDOWS_IGNORED_SPACE_CHAR_CODE = 0x20; +const WINDOWS_IGNORED_DOT_CHAR_CODE = 0x2e; + +function trimTrailingWindowsSeparators(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === WINDOWS_SEPARATOR_CHAR_CODE) { + end -= 1; + } + return end === value.length ? value : value.slice(0, end); +} + +function trimTrailingWindowsIgnoredChars(value: string): string { + let end = value.length; + while (end > 0) { + const charCode = value.charCodeAt(end - 1); + if ( + charCode !== WINDOWS_IGNORED_SPACE_CHAR_CODE && + charCode !== WINDOWS_IGNORED_DOT_CHAR_CODE + ) { + break; + } + end -= 1; + } + return end === value.length ? value : value.slice(0, end); +} + function candidateReadPaths(filePath: string): string[] { if (!filePath.startsWith("file://")) { return [filePath]; @@ -97,10 +124,10 @@ function matchPosixDeviceReadPath( } function normalizeWindowsDeviceBaseName(filePath: string): string { - const normalized = filePath.replace(/\//g, "\\").replace(/[\\]+$/g, ""); + const normalized = trimTrailingWindowsSeparators(filePath.replace(/\//g, "\\")); const lastSegment = normalized.split("\\").filter(Boolean).at(-1) ?? normalized; const withoutStream = lastSegment.split(":")[0] ?? lastSegment; - const withoutTrailingIgnoredChars = withoutStream.replace(/[ .]+$/g, ""); + const withoutTrailingIgnoredChars = trimTrailingWindowsIgnoredChars(withoutStream); return (withoutTrailingIgnoredChars.split(".")[0] ?? withoutTrailingIgnoredChars).toUpperCase(); } diff --git a/src/safe-path-segment.ts b/src/safe-path-segment.ts index 519694f..8c1f2a5 100644 --- a/src/safe-path-segment.ts +++ b/src/safe-path-segment.ts @@ -2,12 +2,25 @@ import { FsSafeError } from "./errors.js"; const SAFE_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/; const SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; +const HYPHEN_CHAR_CODE = 0x2d; export type SafePathSegmentOptions = { allowDotPrefix?: boolean; label?: string; }; +function trimHyphenEdges(value: string): string { + let start = 0; + let end = value.length; + while (start < end && value.charCodeAt(start) === HYPHEN_CHAR_CODE) { + start += 1; + } + while (end > start && value.charCodeAt(end - 1) === HYPHEN_CHAR_CODE) { + end -= 1; + } + return start === 0 && end === value.length ? value : value.slice(start, end); +} + export function isSafePathSegment( segment: string, options: SafePathSegmentOptions = {}, @@ -50,10 +63,10 @@ export function sanitizeSafePathSegment( .trim() .replace(/[\\/]+/g, "-") .replace(/\0/g, "") - .replace(/[^A-Za-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, ""); - if (isSafePathSegment(sanitized, options)) { - return sanitized; + .replace(/[^A-Za-z0-9._-]+/g, "-"); + const trimmed = trimHyphenEdges(sanitized); + if (isSafePathSegment(trimmed, options)) { + return trimmed; } return assertSafePathSegment(fallback, { ...options, label: "fallback path segment" }); } diff --git a/src/temp-target.ts b/src/temp-target.ts index 9bb70f7..08c0464 100644 --- a/src/temp-target.ts +++ b/src/temp-target.ts @@ -13,8 +13,65 @@ export type TempFile = { [Symbol.asyncDispose](): Promise; }; +const HYPHEN_CHAR_CODE = 0x2d; +const DOT_CHAR_CODE = 0x2e; +const NUMBER_ZERO_CHAR_CODE = 0x30; +const NUMBER_NINE_CHAR_CODE = 0x39; +const UPPERCASE_A_CHAR_CODE = 0x41; +const UPPERCASE_Z_CHAR_CODE = 0x5a; +const UNDERSCORE_CHAR_CODE = 0x5f; +const LOWERCASE_A_CHAR_CODE = 0x61; +const LOWERCASE_Z_CHAR_CODE = 0x7a; + +function trimHyphenEdges(value: string): string { + let start = 0; + let end = value.length; + while (start < end && value.charCodeAt(start) === HYPHEN_CHAR_CODE) { + start += 1; + } + while (end > start && value.charCodeAt(end - 1) === HYPHEN_CHAR_CODE) { + end -= 1; + } + return start === 0 && end === value.length ? value : value.slice(start, end); +} + +function isExtensionCharCode(charCode: number): boolean { + return ( + (charCode >= NUMBER_ZERO_CHAR_CODE && charCode <= NUMBER_NINE_CHAR_CODE) || + (charCode >= UPPERCASE_A_CHAR_CODE && charCode <= UPPERCASE_Z_CHAR_CODE) || + (charCode >= LOWERCASE_A_CHAR_CODE && charCode <= LOWERCASE_Z_CHAR_CODE) || + charCode === DOT_CHAR_CODE || + charCode === UNDERSCORE_CHAR_CODE || + charCode === HYPHEN_CHAR_CODE + ); +} + +function trailingExtensionChars(value: string): string { + let start = value.length; + while (start > 0 && isExtensionCharCode(value.charCodeAt(start - 1))) { + start -= 1; + } + return start === value.length ? "" : value.slice(start); +} + +function trimLeadingExtensionPunctuation(value: string): string { + let start = 0; + while (start < value.length) { + const charCode = value.charCodeAt(start); + if ( + charCode !== DOT_CHAR_CODE && + charCode !== UNDERSCORE_CHAR_CODE && + charCode !== HYPHEN_CHAR_CODE + ) { + break; + } + start += 1; + } + return start === 0 ? value : value.slice(start); +} + function sanitizePrefix(prefix: string): string { - const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); + const normalized = trimHyphenEdges(prefix.replace(/[^a-zA-Z0-9_-]+/g, "-")); return normalized || "tmp"; } @@ -23,8 +80,8 @@ function sanitizeExtension(extension?: string): string { return ""; } const normalized = extension.startsWith(".") ? extension : `.${extension}`; - const suffix = normalized.match(/[a-zA-Z0-9._-]+$/)?.[0] ?? ""; - const token = suffix.replace(/^[._-]+/, ""); + const suffix = trailingExtensionChars(normalized); + const token = trimLeadingExtensionPunctuation(suffix); return token ? `.${token}` : ""; } diff --git a/test/codeql-redos-regression.test.ts b/test/codeql-redos-regression.test.ts new file mode 100644 index 0000000..0ee8697 --- /dev/null +++ b/test/codeql-redos-regression.test.ts @@ -0,0 +1,84 @@ +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { describe, expect, it } from "vitest"; +import { isUnsafeDeviceReadPath } from "../src/device-path.js"; +import { sanitizeSafePathSegment } from "../src/safe-path-segment.js"; +import { buildRandomTempFilePath } from "../src/temp-target.js"; + +const ADVERSARIAL_RUN_LENGTH = 100_000; +const MAX_LINEAR_SCAN_MS = 1_000; + +function expectBounded(run: () => T): T { + const startedAt = performance.now(); + const result = run(); + expect(performance.now() - startedAt).toBeLessThan(MAX_LINEAR_SCAN_MS); + return result; +} + +describe("CodeQL ReDoS regressions", () => { + it("handles long internal Windows separator runs in linear time", () => { + const separators = "\\".repeat(ADVERSARIAL_RUN_LENGTH); + + expect( + expectBounded(() => + isUnsafeDeviceReadPath(`C:\\tmp\\${separators}normal.txt`, { + platform: "win32", + }), + ), + ).toBe(false); + expect( + expectBounded(() => + isUnsafeDeviceReadPath(`C:\\tmp\\${separators}NUL`, { + platform: "win32", + }), + ), + ).toBe(true); + }); + + it("preserves long internal hyphen runs while trimming only edges", () => { + const internalHyphens = `a${"-".repeat(ADVERSARIAL_RUN_LENGTH)}b`; + + expect( + expectBounded(() => sanitizeSafePathSegment(internalHyphens, "fallback")), + ).toBe(internalHyphens); + expect(sanitizeSafePathSegment(`---${internalHyphens}---`, "fallback")).toBe( + internalHyphens, + ); + }); + + it("sanitizes long temp prefixes and extension candidates in linear time", () => { + const internalHyphens = `a${"-".repeat(ADVERSARIAL_RUN_LENGTH)}b`; + const prefixed = expectBounded(() => + buildRandomTempFilePath({ + rootDir: process.cwd(), + prefix: internalHyphens, + now: 1, + uuid: "id", + }), + ); + expect(path.basename(prefixed)).toBe(`${internalHyphens}-1-id`); + + const invalidExtension = expectBounded(() => + buildRandomTempFilePath({ + rootDir: process.cwd(), + prefix: "tmp", + extension: `${"a".repeat(ADVERSARIAL_RUN_LENGTH)}!`, + now: 1, + uuid: "id", + }), + ); + expect(path.basename(invalidExtension)).toBe("tmp-1-id"); + + expect( + path.basename( + buildRandomTempFilePath({ + rootDir: process.cwd(), + prefix: "tmp", + extension: `${".".repeat(ADVERSARIAL_RUN_LENGTH)}log`, + now: 1, + uuid: "id", + }), + ), + ).toBe("tmp-1-id.log"); + }); +});