aboutsummaryrefslogtreecommitdiff
path: root/src/profile
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/profile
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/profile')
-rw-r--r--src/profile/browser-process.ts42
-rw-r--r--src/profile/list.ts19
-rw-r--r--src/profile/store.ts59
3 files changed, 74 insertions, 46 deletions
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 : "",
+ };
};
/**