From 773da036bda0ef5155607fceb4abc9d6f4671063 Mon Sep 17 00:00:00 2001 From: ArianAr Date: Thu, 16 Jul 2026 00:29:08 +0300 Subject: [PATCH] fix(security): clear remaining CodeQL workflow and ReDoS alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI: explicit permissions.contents: read (missing-workflow-permissions) - stripAnsi: linear scan instead of nested quantifier regexes (js/polynomial-redos); incomplete OSC no longer eats trailing text Addresses code scanning alerts #2, #6–#11. --- .github/workflows/ci.yml | 4 ++ CHANGELOG.md | 2 + server/lib/metadata.test.ts | 20 ++++++++++ server/lib/metadata.ts | 76 ++++++++++++++++++++++++++++++++++--- 4 files changed, 97 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a720eb..255c543 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least privilege: jobs only need to read the repo (no write tokens). +permissions: + contents: read + env: NEXT_TELEMETRY_DISABLED: "1" CI: "true" diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c0331..21eebb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Theme FOUC boot script is a static string (no `JSON.stringify` interpolation) to clear CodeQL `js/bad-code-sanitization` +- CI workflow sets explicit `permissions: contents: read` (CodeQL `actions/missing-workflow-permissions`) +- `stripAnsi` uses a linear scan instead of nested quantifier regexes (CodeQL `js/polynomial-redos`) ### Planned diff --git a/server/lib/metadata.test.ts b/server/lib/metadata.test.ts index ffe7f65..0b4213b 100644 --- a/server/lib/metadata.test.ts +++ b/server/lib/metadata.test.ts @@ -10,6 +10,26 @@ describe("stripAnsi", () => { it("removes CSI color sequences", () => { expect(stripAnsi("\u001b[31merror\u001b[0m")).toBe("error"); }); + + it("removes OSC sequences terminated by BEL or ST", () => { + expect(stripAnsi("\u001b]0;title\u0007plain")).toBe("plain"); + expect(stripAnsi("\u001b]8;;https://ex\u001b\\link")).toBe("link"); + }); + + it("removes two-byte Fe sequences and lone ESC", () => { + expect(stripAnsi("a\u001bMb")).toBe("ab"); + expect(stripAnsi("x\u001by")).toBe("xy"); + }); + + it("handles incomplete sequences without hanging", () => { + // Incomplete CSI: drop from ESC to end (no final byte) + expect(stripAnsi("\u001b[31")).toBe(""); + // Incomplete OSC: drop ESC] only; keep following plain text + expect(stripAnsi("\u001b]noend")).toBe("noend"); + // Many ESC] prefixes without terminators (former ReDoS shape) + const hostile = `${"\u001b]".repeat(200)}text`; + expect(stripAnsi(hostile)).toBe("text"); + }); }); describe("detectLogLevel", () => { diff --git a/server/lib/metadata.ts b/server/lib/metadata.ts index babb14b..5a7eac0 100644 --- a/server/lib/metadata.ts +++ b/server/lib/metadata.ts @@ -30,12 +30,78 @@ const BRACKETED_LEVEL_RE = const LEADING_LEVEL_RE = /^\s*(?:[\d\-T:.Z]+\s+)?(?:\[)?(FATAL|ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE)(?:\])?\b/i; -/** Strip common ANSI CSI / OSC sequences for metadata and search. */ +/** + * Strip common ANSI CSI / OSC / 2-byte sequences for metadata and search. + * Hand-rolled linear scan (no nested quantifiers) to avoid ReDoS on hostile input. + */ export function stripAnsi(input: string): string { - return input - .replace(/\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g, "") - .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "") - .replace(/\u001b[@-Z\\-_]/g, ""); + let out = ""; + let i = 0; + const n = input.length; + + while (i < n) { + if (input.charCodeAt(i) !== 0x1b /* ESC */) { + out += input[i]!; + i += 1; + continue; + } + + const next = i + 1 < n ? input.charCodeAt(i + 1) : -1; + + // CSI: ESC [ ... final byte in @-~ + if (next === 0x5b /* [ */) { + i += 2; + while (i < n) { + const c = input.charCodeAt(i); + i += 1; + if (c >= 0x40 && c <= 0x7e) break; + } + continue; + } + + // OSC: ESC ] ... BEL (0x07) or ST (ESC \) + // Incomplete OSC (no terminator) only drops ESC] — never eats following text. + if (next === 0x5d /* ] */) { + const bodyStart = i + 2; + let j = bodyStart; + let terminatedAt = -1; + while (j < n) { + const c = input.charCodeAt(j); + if (c === 0x07) { + terminatedAt = j + 1; + break; + } + if (c === 0x1b) { + if (j + 1 < n && input.charCodeAt(j + 1) === 0x5c /* \ */) { + terminatedAt = j + 2; // ST + } + // Else another ESC: abort body; outer loop reprocesses from j. + break; + } + j += 1; + } + if (terminatedAt !== -1) { + i = terminatedAt; + } else if (j < n && input.charCodeAt(j) === 0x1b) { + i = j; + } else { + // EOF without terminator — keep body as plain text + i = bodyStart; + } + continue; + } + + // 2-byte Fe sequences: ESC @ through ESC _ + if (next >= 0x40 && next <= 0x5f) { + i += 2; + continue; + } + + // Lone ESC — drop it + i += 1; + } + + return out; } function normalizeLevel(raw: string): Exclude {