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:
@@ -320,6 +320,59 @@ describe("useChat", () => {
|
||||
expect(result.current.conversationId).toBe("conv-123");
|
||||
expect(result.current.conversationTitle).toBe("My Conversation");
|
||||
});
|
||||
|
||||
it("should fall back to welcome message when stored JSON is corrupted", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mockGetIdea.mockResolvedValueOnce(
|
||||
createMockIdea("conv-bad", "Corrupted", "not valid json {{{")
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadConversation("conv-bad");
|
||||
});
|
||||
|
||||
// Should fall back to welcome message
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||
});
|
||||
|
||||
it("should fall back to welcome message when stored data has wrong shape", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
// Valid JSON but wrong shape (object instead of array, missing required fields)
|
||||
mockGetIdea.mockResolvedValueOnce(
|
||||
createMockIdea("conv-bad", "Wrong Shape", JSON.stringify({ not: "an array" }))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadConversation("conv-bad");
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||
});
|
||||
|
||||
it("should fall back to welcome message when messages have invalid roles", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const badMessages = [
|
||||
{ id: "msg-1", role: "hacker", content: "Bad", createdAt: "2026-01-01" },
|
||||
];
|
||||
mockGetIdea.mockResolvedValueOnce(
|
||||
createMockIdea("conv-bad", "Bad Roles", JSON.stringify(badMessages))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadConversation("conv-bad");
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.id).toBe("welcome");
|
||||
});
|
||||
});
|
||||
|
||||
describe("startNewConversation", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { sendChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
|
||||
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
||||
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
@@ -111,15 +112,10 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Deserialize messages from JSON
|
||||
* Deserialize messages from JSON with runtime type validation
|
||||
*/
|
||||
const deserializeMessages = useCallback((json: string): Message[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Message[];
|
||||
return Array.isArray(parsed) ? parsed : [WELCOME_MESSAGE];
|
||||
} catch {
|
||||
return [WELCOME_MESSAGE];
|
||||
}
|
||||
return safeJsonParse(json, isMessageArray, [WELCOME_MESSAGE]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("useChatOverlay", () => {
|
||||
});
|
||||
|
||||
it("should handle invalid localStorage data gracefully", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
localStorageMock.setItem("chatOverlayState", "invalid json");
|
||||
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
@@ -71,6 +72,37 @@ describe("useChatOverlay", () => {
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
|
||||
it("should fall back to defaults when localStorage has wrong shape", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
// Valid JSON but wrong shape
|
||||
localStorageMock.setItem("chatOverlayState", JSON.stringify({ isOpen: "yes", wrong: true }));
|
||||
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
|
||||
it("should fall back to defaults when localStorage has null value parsed", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
localStorageMock.setItem("chatOverlayState", "null");
|
||||
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
|
||||
it("should fall back to defaults when localStorage has array instead of object", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
localStorageMock.setItem("chatOverlayState", JSON.stringify([true, false]));
|
||||
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("open", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { safeJsonParse, isChatOverlayState } from "@/lib/utils/safe-json";
|
||||
|
||||
interface ChatOverlayState {
|
||||
isOpen: boolean;
|
||||
@@ -27,7 +28,7 @@ const DEFAULT_STATE: ChatOverlayState = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Load state from localStorage
|
||||
* Load state from localStorage with runtime type validation
|
||||
*/
|
||||
function loadState(): ChatOverlayState {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -37,7 +38,7 @@ function loadState(): ChatOverlayState {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as ChatOverlayState;
|
||||
return safeJsonParse(stored, isChatOverlayState, DEFAULT_STATE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load chat overlay state from localStorage:", error);
|
||||
|
||||
Reference in New Issue
Block a user