fix(SEC-WEB-30+31+36): Validate JSON.parse/localStorage deserialization
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add runtime type validation after all JSON.parse calls in the web app to prevent runtime crashes from corrupted or tampered storage data. Creates a shared safeJsonParse utility with type guard functions for each data shape (Message[], ChatOverlayState, LayoutConfigRecord). All four affected callsites now validate parsed data and fall back to safe defaults on mismatch. Files changed: - apps/web/src/lib/utils/safe-json.ts (new utility) - apps/web/src/lib/utils/safe-json.test.ts (25 tests) - apps/web/src/hooks/useChat.ts (deserializeMessages) - apps/web/src/hooks/useChat.test.ts (3 new corruption tests) - apps/web/src/hooks/useChatOverlay.ts (loadState) - apps/web/src/hooks/useChatOverlay.test.ts (3 new corruption tests) - apps/web/src/components/chat/ConversationSidebar.tsx (ideaToConversation) - apps/web/src/lib/hooks/useLayout.ts (layout loading) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
125
apps/web/src/lib/utils/safe-json.ts
Normal file
125
apps/web/src/lib/utils/safe-json.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file safe-json.ts
|
||||
* @description Safe JSON parsing utilities with runtime type validation.
|
||||
* Prevents runtime crashes from corrupted or tampered localStorage/API data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely parse a JSON string with runtime type validation.
|
||||
* Returns the fallback value if parsing fails or the parsed data
|
||||
* doesn't match the expected shape.
|
||||
*
|
||||
* @param json - The JSON string to parse
|
||||
* @param validator - A type guard function that validates the parsed data
|
||||
* @param fallback - The default value to return on failure
|
||||
* @returns The validated parsed data or the fallback value
|
||||
*/
|
||||
export function safeJsonParse<T>(
|
||||
json: string,
|
||||
validator: (data: unknown) => data is T,
|
||||
fallback: T
|
||||
): T {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
if (validator(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
console.warn("safeJsonParse: parsed data failed validation, returning fallback");
|
||||
return fallback;
|
||||
} catch {
|
||||
console.warn("safeJsonParse: failed to parse JSON, returning fallback");
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: validates that a value is a non-null object
|
||||
*/
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: validates a chat Message shape
|
||||
* Checks for required fields: id (string), role (valid enum), content (string), createdAt (string)
|
||||
*/
|
||||
export function isMessage(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const validRoles = ["user", "assistant", "system"];
|
||||
return (
|
||||
typeof value.id === "string" &&
|
||||
typeof value.role === "string" &&
|
||||
validRoles.includes(value.role) &&
|
||||
typeof value.content === "string" &&
|
||||
typeof value.createdAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: validates an array of chat Messages
|
||||
*/
|
||||
export function isMessageArray(value: unknown): value is {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
thinking?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
}[] {
|
||||
return Array.isArray(value) && value.every(isMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: validates ChatOverlayState shape
|
||||
* Expects { isOpen: boolean, isMinimized: boolean }
|
||||
*/
|
||||
export function isChatOverlayState(
|
||||
value: unknown
|
||||
): value is { isOpen: boolean; isMinimized: boolean } {
|
||||
if (!isRecord(value)) return false;
|
||||
return typeof value.isOpen === "boolean" && typeof value.isMinimized === "boolean";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: validates a WidgetPlacement shape
|
||||
* Checks for required fields: i (string), x/y/w/h (numbers)
|
||||
*/
|
||||
function isWidgetPlacement(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.i === "string" &&
|
||||
typeof value.x === "number" &&
|
||||
typeof value.y === "number" &&
|
||||
typeof value.w === "number" &&
|
||||
typeof value.h === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: validates a LayoutConfig shape
|
||||
* Expects { id: string, name: string, layout: WidgetPlacement[] }
|
||||
*/
|
||||
function isLayoutConfig(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.id === "string" &&
|
||||
typeof value.name === "string" &&
|
||||
Array.isArray(value.layout) &&
|
||||
(value.layout as unknown[]).every(isWidgetPlacement)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: validates a Record<string, LayoutConfig> shape.
|
||||
* Uses a branded type approach to ensure compatibility with LayoutConfig consumers.
|
||||
*/
|
||||
export function isLayoutConfigRecord(
|
||||
value: unknown
|
||||
): value is Record<string, { id: string; name: string; layout: unknown[] }> {
|
||||
if (!isRecord(value)) return false;
|
||||
return Object.values(value).every(isLayoutConfig);
|
||||
}
|
||||
Reference in New Issue
Block a user