Skip to content
This repository was archived by the owner on Oct 1, 2026. It is now read-only.
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
75 changes: 2 additions & 73 deletions src/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import { DIR_MODE } from "./auth.js";
import { truncateToBytes } from "./bounds.js";
import { assertUniqueJsonObjectKeys } from "./json-guard.js";
import { redactSensitiveText, sanitizeTerminalOutput } from "./redaction.js";

function isAbsoluteSocketPath(socketPath: string): boolean {
Expand Down Expand Up @@ -377,78 +378,6 @@ export function validateEnvelopeShape(value: unknown): string[] {
return [...new Set(problems)].slice(0, 8).map(safeReportText);
}

/**
* Reject a ctl stdout envelope that repeats an object key. `JSON.parse` keeps
* the last occurrence, so a duplicate key lets a hostile target show one value
* to the operator and hand a different one to every later consumer.
* Single-line JSONL only: `parseCtlEnvelopeShape` rejects embedded newlines
* before this runs.
*/
function assertCtlEnvelopeUniqueKeys(raw: string): void {
const stack: Array<
{ kind: "object"; keys: Set<string> } | { kind: "array" }
> = [];
for (let index = 0; index < raw.length; index += 1) {
const character = raw[index];
if (character === "{") {
stack.push({ kind: "object", keys: new Set<string>() });
continue;
}
if (character === "[") {
stack.push({ kind: "array" });
continue;
}
if (character === "}" || character === "]") {
const expected = character === "}" ? "object" : "array";
const current = stack.pop();
if (current?.kind !== expected) {
throw new SyntaxError("JSON contains mismatched structure");
}
continue;
}
if (character !== '"') continue;
let end = index + 1;
let escaped = false;
for (; end < raw.length; end += 1) {
const code = raw.charCodeAt(end);
if (escaped) {
escaped = false;
continue;
}
if (raw[end] === "\\") {
escaped = true;
continue;
}
if (raw[end] === '"') break;
if (code < 0x20) {
throw new SyntaxError("JSON string contains a control character");
}
}
if (end >= raw.length) {
throw new SyntaxError("JSON contains an unterminated string");
}
const current = stack.at(-1);
if (current?.kind === "object") {
let next = end + 1;
while (next < raw.length && /\s/u.test(raw[next] ?? "")) next += 1;
if (raw[next] === ":") {
const parsedKey: unknown = JSON.parse(raw.slice(index, end + 1));
if (typeof parsedKey !== "string") {
throw new SyntaxError("JSON object key is not a string");
}
if (current.keys.has(parsedKey)) {
throw new SyntaxError("JSON contains a duplicate object key");
}
current.keys.add(parsedKey);
}
}
index = end;
}
if (stack.length > 0) {
throw new SyntaxError("JSON contains unclosed structure");
}
}

function parseCtlEnvelopeShape(stdout: string): CtlEnvelope {
if (utf8Bytes(stdout) > MAX_CAMPAIGN_OUTPUT_BYTES) {
throw new CampaignError(
Expand All @@ -470,7 +399,7 @@ function parseCtlEnvelopeShape(stdout: string): CtlEnvelope {
}
let parsed: unknown;
try {
assertCtlEnvelopeUniqueKeys(line);
assertUniqueJsonObjectKeys(line);
parsed = JSON.parse(line);
} catch {
throw new CampaignError("InvalidJson", "ctl stdout is not valid JSON");
Expand Down
10 changes: 2 additions & 8 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,15 +621,9 @@ export class DevtoolsClient {
throw new TransportError("TransportClosed", "request cancelled");
}
transport.getRateLimiter().check(nowMs);
const frames = transport.encodeRequest(req);
if (frames.length !== 1) {
throw new TransportError(
"FrameTooLarge",
"live request requires a server continuation contract",
);
}
const frame = transport.encodeSingleLiveRequest(req);
const raw = await live.requestResponse(
frames[0]!.slice(4),
frame.slice(4),
nowMs,
req.id,
signal,
Expand Down
85 changes: 85 additions & 0 deletions src/json-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Shared structural guards for untrusted single-record JSON input.
*
* `JSON.parse` keeps the last occurrence of a duplicate object key, so a
* hostile record can show one value to a human reader and hand a different
* one to every later consumer. This scanner rejects a duplicate object key
* (and malformed structure) before parsing, without building a second object
* graph. It is the single implementation used by the campaign ctl-envelope
* validator and the debug-protocol response decoder.
*
* Callers pass a single JSONL line; embedded newlines are rejected by the
* callers before this runs.
*/

export class DuplicateJsonKeyError extends SyntaxError {
constructor(public readonly key: string) {
super("JSON contains a duplicate object key");
this.name = "DuplicateJsonKeyError";
}
}

export function assertUniqueJsonObjectKeys(raw: string): void {
const stack: Array<
{ kind: "object"; keys: Set<string> } | { kind: "array" }
> = [];
for (let index = 0; index < raw.length; index += 1) {
const character = raw[index];
if (character === "{") {
stack.push({ kind: "object", keys: new Set<string>() });
continue;
}
if (character === "[") {
stack.push({ kind: "array" });
continue;
}
if (character === "}" || character === "]") {
const expected = character === "}" ? "object" : "array";
const current = stack.pop();
if (current?.kind !== expected) {
throw new SyntaxError("JSON contains mismatched structure");
}
continue;
}
if (character !== '"') continue;
let end = index + 1;
let escaped = false;
for (; end < raw.length; end += 1) {
const code = raw.charCodeAt(end);
if (escaped) {
escaped = false;
continue;
}
if (raw[end] === "\\") {
escaped = true;
continue;
}
if (raw[end] === '"') break;
if (code < 0x20) {
throw new SyntaxError("JSON string contains a control character");
}
}
if (end >= raw.length) {
throw new SyntaxError("JSON contains an unterminated string");
}
const current = stack.at(-1);
if (current?.kind === "object") {
let next = end + 1;
while (next < raw.length && /\s/u.test(raw[next] ?? "")) next += 1;
if (raw[next] === ":") {
const parsedKey: unknown = JSON.parse(raw.slice(index, end + 1));
if (typeof parsedKey !== "string") {
throw new SyntaxError("JSON object key is not a string");
}
if (current.keys.has(parsedKey)) {
throw new DuplicateJsonKeyError(parsedKey);
}
current.keys.add(parsedKey);
}
}
index = end;
}
if (stack.length > 0) {
throw new SyntaxError("JSON contains unclosed structure");
}
}
10 changes: 6 additions & 4 deletions src/protocol-boundary.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/**
* CTX-0080 live request admission boundary.
*
* CTX-0079 owns src/protocol.ts decoding, duplicate-key handling, payload
* bounds, and redaction. This module owns only the live request admission
* checks added by CTX-0080: supported protocol version, registered method,
* exact scope, and the shared protocol encoder's size/shape validation.
* src/protocol.ts owns debug-protocol response decoding, duplicate-key
* rejection (shared scanner in src/json-guard.ts), payload bounds, and error
* shaping; it does not own redaction. This module owns only the live request
* admission checks added by CTX-0080: supported protocol version, registered
* method, exact scope, and the shared protocol encoder's size/shape
* validation.
*/

import {
Expand Down
Loading
Loading