fix(security): escape field names in transcript regex extraction

extractJsonStringFieldPrefix and extractJsonNullableStringFieldPrefix
interpolate the `field` parameter into `new RegExp(...)` without
escaping.  All current callers pass hardcoded strings ("id",
"parentId", "type", "role"), but the function signature accepts
any string.  A future caller passing a field containing regex
metacharacters (e.g. "foo.bar") would match unintended patterns.

Wrap the interpolation with escapeRegExp() from src/shared/regexp.ts
so metacharacters are treated literally.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
This commit is contained in:
Sebastien Tardif
2026-05-23 22:03:18 -07:00
committed by Peter Steinberger
parent 78a1e7dfe6
commit fe8d99d421
2 changed files with 36 additions and 2 deletions
+33
View File
@@ -1982,6 +1982,39 @@ describe("oversized transcript line guards", () => {
expectUsageFields(usage, { modelProvider: "test-provider" });
});
test("oversized line metadata extraction preserves id and parentId", async () => {
const sessionId = "test-oversized-metadata-extract";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const oversizedContent = "w".repeat(300 * 1024);
const lines = [
JSON.stringify({ type: "session", version: 3, id: sessionId }),
JSON.stringify({
type: "message",
id: "root-msg",
parentId: null,
message: { role: "user", content: "root" },
}),
JSON.stringify({
type: "message",
id: "oversized-child",
parentId: "root-msg",
message: { role: "assistant", content: oversizedContent },
}),
];
fs.writeFileSync(transcriptPath, `${lines.join("\n")}\n`, "utf-8");
const out = await readRecentSessionMessagesAsync(sessionId, storePath, undefined, {
maxMessages: 10,
});
const serialized = JSON.stringify(out);
// The oversized line's id and parentId must be extracted correctly
// from the prefix via regex-based field extraction.
expect(serialized).toContain("oversized-child");
expect(serialized).toContain("root-msg");
expect(serialized).not.toContain(oversizedContent);
});
test("readSessionTitleFieldsFromTranscriptAsync delegates to bounded sync reader", async () => {
const sessionId = "test-async-title-bounded";
writeTranscript(
+3 -2
View File
@@ -4,6 +4,7 @@ import { deriveSessionTotalTokens, hasNonzeroUsage, normalizeUsage } from "../ag
import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js";
import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js";
import { extractAssistantVisibleText } from "../shared/chat-message-content.js";
import { escapeRegExp } from "../shared/regexp.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { estimateStringChars, estimateTokensFromChars } from "../utils/cjk-chars.js";
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
@@ -273,7 +274,7 @@ function isOversizedTranscriptLine(line: string): boolean {
}
function extractJsonStringFieldPrefix(prefix: string, field: string): string | undefined {
const match = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(prefix);
const match = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(prefix);
if (!match) {
return undefined;
}
@@ -289,7 +290,7 @@ function extractJsonNullableStringFieldPrefix(
prefix: string,
field: string,
): string | null | undefined {
if (new RegExp(`"${field}"\\s*:\\s*null`).test(prefix)) {
if (new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*null`).test(prefix)) {
return null;
}
return extractJsonStringFieldPrefix(prefix, field);