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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions server/lib/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
76 changes: 71 additions & 5 deletions server/lib/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogLevel, "UNKNOWN"> {
Expand Down
Loading