aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-28 00:53:19 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commitb9c77bb6f9b8b83574489344ef6efd207a611e98 (patch)
tree3c59bf53bcce3ac5c6bbf9a22884b6e6f787dc91 /src
parent1c73f4af643aa60ca554a3770264791d299e64e2 (diff)
refactor: validate JSON at every parse boundary
Ten sites cast or implicitly typed JSON.parse output; JSON.parse("null") would have satisfied all of them. parseJson returns a Result and validates against a typebox schema. js.ts keeps unknown — user script output has no fixed shape. daemon/bridge.ts assigned JSON.parse straight to CdpRawMessage with no guard at all; it now uses isCdpRawMessage like the transport does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/cdp/discovery.ts19
-rw-r--r--src/client.ts60
-rw-r--r--src/daemon/bridge.ts7
-rw-r--r--src/domains/js.ts2
-rw-r--r--src/domains/readpage/read-page.ts42
-rw-r--r--src/domains/search/web-search.ts34
-rw-r--r--src/profile/browser-process.ts42
-rw-r--r--src/profile/list.ts19
-rw-r--r--src/profile/store.ts59
-rw-r--r--src/schemas/parse.ts23
10 files changed, 191 insertions, 116 deletions
diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts
index 295df20..b4be31c 100644
--- a/src/cdp/discovery.ts
+++ b/src/cdp/discovery.ts
@@ -2,6 +2,9 @@ import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { connect as netConnect } from "node:net";
import { request as httpRequest } from "node:http";
+import { Type } from "typebox";
+import { Compile } from "typebox/compile";
+import { parseJson } from "../schemas/parse";
import { type Result, err, ok } from "../util/result";
import { userDataDirCandidates } from "../profile/paths";
import { type CdpError, cdpError } from "./errors";
@@ -9,6 +12,13 @@ import { type CdpError, cdpError } from "./errors";
const PORT_PROBE_DEADLINE_MS = 30_000;
const PORT_PROBE_INTERVAL_MS = 1_000;
+const VersionResponse = Type.Object(
+ { webSocketDebuggerUrl: Type.Optional(Type.String()) },
+ { additionalProperties: true },
+);
+
+const versionValidator = Compile(VersionResponse);
+
/**
* A live CDP endpoint plus, when discovery went through a DevToolsActivePort
* file, the user-data-dir that file lives in. That directory is the browser's
@@ -76,13 +86,8 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => {
- try {
- const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
- const ws = json["webSocketDebuggerUrl"];
- resolve(typeof ws === "string" ? ws : null);
- } catch {
- resolve(null);
- }
+ const parsed = parseJson(Buffer.concat(chunks).toString("utf8"), versionValidator);
+ resolve(parsed.success ? parsed.data.webSocketDebuggerUrl ?? null : null);
});
},
);
diff --git a/src/client.ts b/src/client.ts
index c1c8951..ad35826 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,3 +1,6 @@
+import { Type } from "typebox";
+import { Compile } from "typebox/compile";
+import { parseJson } from "./schemas/parse";
import { type Result, err, ok } from "./util/result";
import { safeJs } from "./util/js-template";
import { type Mutex, createMutex } from "./util/mutex";
@@ -75,31 +78,34 @@ export type BrowserClient = {
const HEALTH_TTL_MS = 30_000;
const PAGE_INFO_TTL_MS = 1_000;
-const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
- if (typeof v !== "object" || v === null) {
- return err(cdpError("invalid_response", "page info payload is not an object"));
- }
- const o = v as Readonly<Record<string, unknown>>;
- const fields: ReadonlyArray<readonly [string, "string" | "number"]> = [
- ["url", "string"], ["title", "string"],
- ["w", "number"], ["h", "number"],
- ["sx", "number"], ["sy", "number"],
- ["pw", "number"], ["ph", "number"],
- ];
- for (const [k, t] of fields) {
- if (typeof o[k] !== t) {
- return err(cdpError("invalid_response", `page info field ${k} has wrong type (expected ${t})`));
- }
+const PageInfoPayload = Type.Object({
+ url: Type.String(),
+ title: Type.String(),
+ w: Type.Number(),
+ h: Type.Number(),
+ sx: Type.Number(),
+ sy: Type.Number(),
+ pw: Type.Number(),
+ ph: Type.Number(),
+});
+
+const pageInfoValidator = Compile(PageInfoPayload);
+
+const parsePageInfoPayload = (raw: string): Result<PageInfo, CdpError> => {
+ const parsed = parseJson(raw, pageInfoValidator);
+ if (!parsed.success) {
+ return err(cdpError("invalid_response", `page info payload is invalid: ${parsed.error}`));
}
+ const o = parsed.data;
return ok({
- url: o["url"] as string,
- title: o["title"] as string,
- width: o["w"] as number,
- height: o["h"] as number,
- scrollX: o["sx"] as number,
- scrollY: o["sy"] as number,
- pageWidth: o["pw"] as number,
- pageHeight: o["ph"] as number,
+ url: o.url,
+ title: o.title,
+ width: o.w,
+ height: o.h,
+ scrollX: o.sx,
+ scrollY: o.sy,
+ pageWidth: o.pw,
+ pageHeight: o.ph,
});
};
@@ -259,13 +265,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
const raw = await evaluateJs(expr);
if (!raw.success) return raw;
if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string"));
- let parsedRaw: unknown;
- try {
- parsedRaw = JSON.parse(raw.data);
- } catch (e) {
- return err(cdpError("invalid_response", `page info JSON.parse failed: ${e instanceof Error ? e.message : String(e)}`));
- }
- const info = parsePageInfoPayload(parsedRaw);
+ const info = parsePageInfoPayload(raw.data);
if (!info.success) return info;
if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
return ok(info.data);
diff --git a/src/daemon/bridge.ts b/src/daemon/bridge.ts
index 33ef63b..de6dec7 100644
--- a/src/daemon/bridge.ts
+++ b/src/daemon/bridge.ts
@@ -12,6 +12,7 @@
import WebSocket from "ws";
import { discoverWsUrl } from "../cdp/discovery";
+import { isCdpRawMessage } from "../cdp/types";
import type { CdpRawMessage } from "../cdp/types";
import type { WireRequest, WireResponse, WireEvent } from "./protocol";
import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
@@ -150,8 +151,10 @@ export const createCdpBridge = (): CdpBridge => {
// ── Handle raw CDP messages from Chrome ─────────────────────────────────
const onChromeMessage = (raw: string): void => {
- let msg: CdpRawMessage;
- try { msg = JSON.parse(raw); } catch { return; }
+ let parsed: unknown;
+ try { parsed = JSON.parse(raw); } catch { return; }
+ if (!isCdpRawMessage(parsed)) return;
+ const msg: CdpRawMessage = parsed;
// ---- Response (has id) ----
if (msg.id !== undefined) {
diff --git a/src/domains/js.ts b/src/domains/js.ts
index b4a5b4c..61dbe53 100644
--- a/src/domains/js.ts
+++ b/src/domains/js.ts
@@ -61,7 +61,7 @@ export const executeJsTool = defineBrowserTool({
// to the raw string if parse fails (e.g. value is "undefined" sentinel).
let pretty: string | undefined;
try {
- const parsed = JSON.parse(valueStr);
+ const parsed: unknown = JSON.parse(valueStr);
pretty = JSON.stringify(parsed, null, 2);
} catch {
pretty = undefined;
diff --git a/src/domains/readpage/read-page.ts b/src/domains/readpage/read-page.ts
index 453c347..0a6fadc 100644
--- a/src/domains/readpage/read-page.ts
+++ b/src/domains/readpage/read-page.ts
@@ -1,4 +1,6 @@
import { Type } from "typebox";
+import { Compile } from "typebox/compile";
+import { parseJson } from "../../schemas/parse";
import { type Result, err, ok } from "../../util/result";
import { applyTruncation } from "../../util/truncate";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
@@ -24,23 +26,39 @@ const ReadPageArgs = Type.Object({
),
});
-const isPageCapture = (value: unknown): value is PageCapture => {
- if (typeof value !== "object" || value === null) return false;
- const v = value as Record<string, unknown>;
- return typeof v["url"] === "string" && typeof v["title"] === "string" && Array.isArray(v["blocks"]) && typeof v["bodyText"] === "string";
-};
+const ContentBlockSchema = Type.Object({
+ kind: Type.Union([
+ Type.Literal("paragraph"),
+ Type.Literal("heading"),
+ Type.Literal("listitem"),
+ Type.Literal("blockquote"),
+ Type.Literal("other"),
+ ]),
+ text: Type.String(),
+ linkTextLength: Type.Number(),
+ inBoilerplate: Type.Boolean(),
+});
-const safeParse = (text: string): unknown => {
- try {
- return JSON.parse(text) as unknown;
- } catch {
- return undefined;
+const PageCaptureSchema = Type.Object({
+ url: Type.String(),
+ title: Type.String(),
+ blocks: Type.Array(ContentBlockSchema),
+ bodyText: Type.String(),
+});
+
+const pageCaptureValidator = Compile(PageCaptureSchema);
+
+const toPageCapture = (raw: unknown): PageCapture | undefined => {
+ if (typeof raw === "string") {
+ const parsed = parseJson(raw, pageCaptureValidator);
+ return parsed.success ? parsed.data : undefined;
}
+ return pageCaptureValidator.Check(raw) ? raw : undefined;
};
const captureToResult = async (raw: unknown): Promise<Result<ToolOk, ToolErr>> => {
- const parsed = typeof raw === "string" ? safeParse(raw) : raw;
- if (!isPageCapture(parsed)) return err({ kind: "internal", message: "page capture returned an unexpected shape" });
+ const parsed = toPageCapture(raw);
+ if (parsed === undefined) return err({ kind: "internal", message: "page capture returned an unexpected shape" });
const page: ReadablePage = extractReadable(parsed);
const header = `# ${page.title}\n${page.url}\n(${page.wordCount} words)\n\n`;
const truncated = await applyTruncation(header + page.text, "readpage");
diff --git a/src/domains/search/web-search.ts b/src/domains/search/web-search.ts
index 69677b4..9327f15 100644
--- a/src/domains/search/web-search.ts
+++ b/src/domains/search/web-search.ts
@@ -1,4 +1,6 @@
import { Type } from "typebox";
+import { Compile } from "typebox/compile";
+import { parseJson } from "../../schemas/parse";
import { type Result, err, ok } from "../../util/result";
import { applyTruncation } from "../../util/truncate";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
@@ -26,11 +28,14 @@ const WebSearchArgs = Type.Object({
const buildSerpUrl = (query: string, limit: number): string =>
`https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`;
-const isSerpExtraction = (value: unknown): value is SerpExtraction => {
- if (typeof value !== "object" || value === null) return false;
- const v = value as Record<string, unknown>;
- return Array.isArray(v["anchors"]) && typeof v["pageText"] === "string";
-};
+const SerpExtractionSchema = Type.Object({
+ anchors: Type.Array(
+ Type.Object({ href: Type.String(), heading: Type.String(), snippet: Type.String() }),
+ ),
+ pageText: Type.String(),
+});
+
+const serpExtractionValidator = Compile(SerpExtractionSchema);
const formatResults = (query: string, results: ReadonlyArray<SearchResult>): string => {
const lines = results.map((r) => `${r.rank}. ${r.title}\n ${r.url}\n ${r.snippet}`);
@@ -69,13 +74,14 @@ export const webSearchTool = defineBrowserTool({
const extracted = await evalInIsolatedTab(client, tab, buildSerpExtractionExpr(), isJsonText);
if (!extracted.success) return err(extracted.error);
- const parsedJson = safeParse(extracted.data);
- if (!isSerpExtraction(parsedJson)) {
- return err({ kind: "internal", message: "SERP extraction returned an unexpected shape" });
+ const parsedJson = parseJson(extracted.data, serpExtractionValidator);
+ if (!parsedJson.success) {
+ return err({ kind: "internal", message: `SERP extraction returned an unexpected shape: ${parsedJson.error}` });
}
+ const extraction: SerpExtraction = parsedJson.data;
- const results = parseGoogleSerp(parsedJson.anchors, limit);
- const verdict = classifySerp(parsedJson.pageText, results.length);
+ const results = parseGoogleSerp(extraction.anchors, limit);
+ const verdict = classifySerp(extraction.pageText, results.length);
if (verdict !== "ok") {
return err({
kind: "invalid_state",
@@ -108,11 +114,3 @@ export const webSearchTool = defineBrowserTool({
return renderExpandableText("web_search", result, expanded, theme);
},
});
-
-const safeParse = (text: string): unknown => {
- try {
- return JSON.parse(text) as unknown;
- } catch {
- return undefined;
- }
-};
diff --git a/src/profile/browser-process.ts b/src/profile/browser-process.ts
index ac31d4d..5eb9b58 100644
--- a/src/profile/browser-process.ts
+++ b/src/profile/browser-process.ts
@@ -34,6 +34,9 @@ import { execFile } from "node:child_process";
import { access, readFile, readlink } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
+import { Type } from "typebox";
+import { Compile } from "typebox/compile";
+import { parseJson } from "../schemas/parse";
import { browserNameForUserDataDir } from "./paths";
// execFile, not exec: arguments are passed as an array, with no shell involved.
@@ -275,7 +278,17 @@ const POWERSHELL_QUERY = [
"| ConvertTo-Json -Compress",
].join(" ");
-type WinProcess = { ExecutablePath?: string | null; CommandLine?: string | null };
+const WinProcessSchema = Type.Object(
+ {
+ ExecutablePath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
+ CommandLine: Type.Optional(Type.Union([Type.String(), Type.Null()])),
+ },
+ { additionalProperties: true },
+);
+
+const WinProcessesSchema = Type.Union([WinProcessSchema, Type.Array(WinProcessSchema)]);
+
+const winProcessesValidator = Compile(WinProcessesSchema);
const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
try {
@@ -286,19 +299,20 @@ const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => {
);
const trimmed = stdout.trim();
if (trimmed) {
- const parsed: unknown = JSON.parse(trimmed);
- // ConvertTo-Json emits an object for a single row, an array for many.
- const rows: WinProcess[] = Array.isArray(parsed) ? (parsed as WinProcess[]) : [parsed as WinProcess];
- const candidates = rows
- .filter((row) => !hasChildProcessType(row.CommandLine ?? ""))
- .map((row): BrowserCandidate => {
- const explicit = parseUserDataDirFlag(row.CommandLine ?? "");
- return {
- ...(row.ExecutablePath ? { exePath: row.ExecutablePath } : {}),
- ...(explicit ? { explicitUserDataDir: explicit } : {}),
- };
- });
- if (candidates.length > 0) return candidates;
+ const parsed = parseJson(trimmed, winProcessesValidator);
+ if (parsed.success) {
+ const rows = Array.isArray(parsed.data) ? parsed.data : [parsed.data];
+ const candidates = rows
+ .filter((row) => !hasChildProcessType(row.CommandLine ?? ""))
+ .map((row): BrowserCandidate => {
+ const explicit = parseUserDataDirFlag(row.CommandLine ?? "");
+ return {
+ ...(row.ExecutablePath ? { exePath: row.ExecutablePath } : {}),
+ ...(explicit ? { explicitUserDataDir: explicit } : {}),
+ };
+ });
+ if (candidates.length > 0) return candidates;
+ }
}
} catch {
// PowerShell missing or blocked by policy — fall through to tasklist.
diff --git a/src/profile/list.ts b/src/profile/list.ts
index 5a83654..3915a98 100644
--- a/src/profile/list.ts
+++ b/src/profile/list.ts
@@ -21,6 +21,9 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
+import { Type } from "typebox";
+import { Compile } from "typebox/compile";
+import { parseJson } from "../schemas/parse";
export type BrowserProfile = {
/** Profile subdirectory name, e.g. "Default" or "Profile 1". */
@@ -48,21 +51,23 @@ const NON_PROFILE_DIRS: ReadonlySet<string> = new Set([
export const formatProfileLabel = (name: string, email: string | undefined, dir: string): string =>
`${name} (${email && email.length > 0 ? email : dir})`;
+const JsonObject = Type.Record(Type.String(), Type.Unknown());
+
+const jsonObjectValidator = Compile(JsonObject);
+
const readJsonFile = async (path: string): Promise<Record<string, unknown> | null> => {
+ let raw: string;
try {
- const raw = await readFile(path, "utf8");
- const parsed: unknown = JSON.parse(raw);
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
- return parsed as Record<string, unknown>;
+ raw = await readFile(path, "utf8");
} catch {
- // Missing file, permission error, or malformed JSON — the caller falls
- // back to the next tier, and ultimately to an empty list.
return null;
}
+ const parsed = parseJson(raw, jsonObjectValidator);
+ return parsed.success ? parsed.data : null;
};
const asRecord = (v: unknown): Record<string, unknown> | null =>
- typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
+ jsonObjectValidator.Check(v) ? v : null;
const asString = (v: unknown): string | undefined =>
typeof v === "string" && v.length > 0 ? v : undefined;
diff --git a/src/profile/store.ts b/src/profile/store.ts
index c8b8952..e38ff2e 100644
--- a/src/profile/store.ts
+++ b/src/profile/store.ts
@@ -18,6 +18,9 @@
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { dirname } from "node:path";
+import { Type } from "typebox";
+import { Compile } from "typebox/compile";
+import { parseJson } from "../schemas/parse";
import { type Result, err, ok } from "../util/result";
import { pinFilePath } from "./paths";
@@ -39,22 +42,26 @@ type PinFile = {
const CURRENT_VERSION = 1;
-const parsePin = (value: unknown): ProfilePin | null => {
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
- const o = value as Record<string, unknown>;
- const userDataDir = o["userDataDir"];
- const profileDir = o["profileDir"];
- const label = o["label"];
- const savedAt = o["savedAt"];
- if (typeof userDataDir !== "string" || userDataDir.length === 0) return null;
- if (typeof profileDir !== "string" || profileDir.length === 0) return null;
- return {
- userDataDir,
- profileDir,
- label: typeof label === "string" && label.length > 0 ? label : profileDir,
- savedAt: typeof savedAt === "string" ? savedAt : "",
- };
-};
+const PinFileSchema = Type.Object(
+ {
+ version: Type.Literal(CURRENT_VERSION),
+ profile: Type.Union([
+ Type.Object(
+ {
+ userDataDir: Type.String({ minLength: 1 }),
+ profileDir: Type.String({ minLength: 1 }),
+ label: Type.Optional(Type.Unknown()),
+ savedAt: Type.Optional(Type.Unknown()),
+ },
+ { additionalProperties: true },
+ ),
+ Type.Null(),
+ ]),
+ },
+ { additionalProperties: true },
+);
+
+const pinFileValidator = Compile(PinFileSchema);
/**
* The persisted pin, or null when nothing is pinned, the file is missing, or
@@ -68,15 +75,17 @@ export const readPin = async (): Promise<ProfilePin | null> => {
} catch {
return null;
}
- try {
- const parsed: unknown = JSON.parse(raw);
- if (typeof parsed !== "object" || parsed === null) return null;
- const file = parsed as Record<string, unknown>;
- if (file["version"] !== CURRENT_VERSION) return null;
- return parsePin(file["profile"]);
- } catch {
- return null;
- }
+ const parsed = parseJson(raw, pinFileValidator);
+ if (!parsed.success) return null;
+ const profile = parsed.data.profile;
+ if (profile === null) return null;
+ const { userDataDir, profileDir, label, savedAt } = profile;
+ return {
+ userDataDir,
+ profileDir,
+ label: typeof label === "string" && label.length > 0 ? label : profileDir,
+ savedAt: typeof savedAt === "string" ? savedAt : "",
+ };
};
/**
diff --git a/src/schemas/parse.ts b/src/schemas/parse.ts
new file mode 100644
index 0000000..5b43fd9
--- /dev/null
+++ b/src/schemas/parse.ts
@@ -0,0 +1,23 @@
+import type { Static, TSchema } from "typebox";
+import type { Validator } from "typebox/compile";
+import { type Result, err, ok } from "../util/result";
+
+export const parseJson = <S extends TSchema>(
+ raw: string,
+ validator: Validator<{}, S>,
+): Result<Static<S>, string> => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (e) {
+ return err(`JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ if (!validator.Check(parsed)) {
+ const messages = validator
+ .Errors(parsed)
+ .map((x) => x.message)
+ .join("; ");
+ return err(messages.length > 0 ? messages : "JSON did not match the expected shape");
+ }
+ return ok(parsed);
+};