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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 29 additions & 2 deletions src/device-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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();
}

Expand Down
21 changes: 17 additions & 4 deletions src/safe-path-segment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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" });
}
Expand Down
63 changes: 60 additions & 3 deletions src/temp-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,65 @@ export type TempFile = {
[Symbol.asyncDispose](): Promise<void>;
};

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

Expand All @@ -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}` : "";
}

Expand Down
84 changes: 84 additions & 0 deletions test/codeql-redos-regression.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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");
});
});
Loading