Files
stack/apps/web/src/lib/utils/safe-json.ts
Jason Woltje 14b547d468
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(SEC-WEB-30+31+36): Validate JSON.parse/localStorage deserialization
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>
2026-02-06 15:46:58 -06:00

126 lines
3.6 KiB
TypeScript

/**
* @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);
}