fix(app): validate persisted client state (#3289)
* fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import { createValidatedPersistStorage } from "./validated-persist-storage";
|
||||
|
||||
class MemoryStorage implements StateStorage {
|
||||
readonly values = new Map<string, string>();
|
||||
|
||||
getItem(name: string): string | null {
|
||||
return this.values.get(name) ?? null;
|
||||
}
|
||||
|
||||
setItem(name: string, value: string): void {
|
||||
this.values.set(name, value);
|
||||
}
|
||||
|
||||
removeItem(name: string): void {
|
||||
this.values.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const StateSchema = z.strictObject({ enabled: z.boolean() });
|
||||
|
||||
describe("createValidatedPersistStorage", () => {
|
||||
it.each([
|
||||
["malformed JSON", "{"],
|
||||
["invalid state", JSON.stringify({ state: { enabled: "yes" }, version: 1 })],
|
||||
["unknown state fields", JSON.stringify({ state: { enabled: true, extra: true }, version: 1 })],
|
||||
[
|
||||
"unknown envelope fields",
|
||||
JSON.stringify({ state: { enabled: true }, version: 1, extra: true }),
|
||||
],
|
||||
])("clears %s", async (_label, stored) => {
|
||||
const backing = new MemoryStorage();
|
||||
backing.values.set("settings", stored);
|
||||
const storage = createValidatedPersistStorage(backing, StateSchema);
|
||||
|
||||
await expect(storage.getItem("settings")).resolves.toBeNull();
|
||||
expect(backing.values.has("settings")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns schema-validated state", async () => {
|
||||
const backing = new MemoryStorage();
|
||||
backing.values.set("settings", JSON.stringify({ state: { enabled: true }, version: 1 }));
|
||||
const storage = createValidatedPersistStorage(backing, StateSchema);
|
||||
|
||||
await expect(storage.getItem("settings")).resolves.toEqual({
|
||||
state: { enabled: true },
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a value rejected by the schema before writing", async () => {
|
||||
const backing = new MemoryStorage();
|
||||
backing.values.set("settings", JSON.stringify({ state: { count: 1 } }));
|
||||
const storage = createValidatedPersistStorage(
|
||||
backing,
|
||||
z.strictObject({ count: z.number().finite() }),
|
||||
);
|
||||
|
||||
await storage.setItem("settings", { state: { count: Number.NaN } });
|
||||
|
||||
expect(backing.values.has("settings")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import type { PersistStorage, StateStorage } from "zustand/middleware";
|
||||
|
||||
export function createValidatedPersistStorage<State>(
|
||||
backingStorage: StateStorage,
|
||||
stateSchema: z.ZodType<State>,
|
||||
): PersistStorage<State> {
|
||||
const envelopeSchema = z.strictObject({
|
||||
state: stateSchema,
|
||||
version: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
return {
|
||||
getItem: async (name) => {
|
||||
const raw = await backingStorage.getItem(name);
|
||||
if (raw === null) return null;
|
||||
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch {
|
||||
await backingStorage.removeItem(name);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = envelopeSchema.safeParse(decoded);
|
||||
if (!result.success) {
|
||||
await backingStorage.removeItem(name);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
const result = envelopeSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
await backingStorage.removeItem(name);
|
||||
return;
|
||||
}
|
||||
await backingStorage.setItem(name, JSON.stringify(result.data));
|
||||
},
|
||||
removeItem: (name) => backingStorage.removeItem(name),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { readValidatedJson, readValidatedString } from "./validated-storage";
|
||||
|
||||
class MemoryStorage {
|
||||
readonly values = new Map<string, string>();
|
||||
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
this.values.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
describe("validated storage reads", () => {
|
||||
it.each([
|
||||
["malformed JSON", "{"],
|
||||
["wrong field type", JSON.stringify({ enabled: "yes" })],
|
||||
["unknown field", JSON.stringify({ enabled: true, surprise: true })],
|
||||
])("clears invalid JSON payloads: %s", async (_label, raw) => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.values.set("settings", raw);
|
||||
|
||||
await expect(
|
||||
readValidatedJson(storage, "settings", z.strictObject({ enabled: z.boolean() })),
|
||||
).resolves.toBeNull();
|
||||
expect(storage.values.has("settings")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears invalid scalar values", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.values.set("channel", "nightly");
|
||||
|
||||
await expect(
|
||||
readValidatedString(storage, "channel", z.enum(["stable", "beta"])),
|
||||
).resolves.toBeNull();
|
||||
expect(storage.values.has("channel")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns only schema-validated values", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.values.set("settings", JSON.stringify({ enabled: true }));
|
||||
|
||||
await expect(
|
||||
readValidatedJson(storage, "settings", z.strictObject({ enabled: z.boolean() })),
|
||||
).resolves.toEqual({ enabled: true });
|
||||
expect(storage.values.has("settings")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface ValidatedStorage {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
async function clearInvalidValue(storage: ValidatedStorage, key: string): Promise<void> {
|
||||
try {
|
||||
await storage.removeItem(key);
|
||||
} catch {
|
||||
// Invalid persisted data must never cross the boundary, even when cleanup fails.
|
||||
}
|
||||
}
|
||||
|
||||
export async function readValidatedString<Value>(
|
||||
storage: ValidatedStorage,
|
||||
key: string,
|
||||
schema: z.ZodType<Value>,
|
||||
): Promise<Value | null> {
|
||||
const raw = await storage.getItem(key);
|
||||
if (raw === null) return null;
|
||||
const result = schema.safeParse(raw);
|
||||
if (result.success) return result.data;
|
||||
await clearInvalidValue(storage, key);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readValidatedJson<Value>(
|
||||
storage: ValidatedStorage,
|
||||
key: string,
|
||||
schema: z.ZodType<Value>,
|
||||
): Promise<Value | null> {
|
||||
const raw = await storage.getItem(key);
|
||||
if (raw === null) return null;
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch {
|
||||
await clearInvalidValue(storage, key);
|
||||
return null;
|
||||
}
|
||||
const result = schema.safeParse(decoded);
|
||||
if (result.success) return result.data;
|
||||
await clearInvalidValue(storage, key);
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user