fix(#337): Add Zod validation for Redis deserialization
- Created Zod schemas for TaskState, AgentState, and OrchestratorEvent - Added ValkeyValidationError class for detailed error context - Validate task and agent state data after JSON.parse - Validate events in subscribeToEvents handler - Corrupted/tampered data now rejected with clear errors including: - Key name for context - Data snippet (truncated to 100 chars) - Underlying Zod validation error - Prevents silent propagation of invalid data (SEC-ORCH-6) - Added 20 new tests for validation scenarios Refs #337 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import Redis from "ioredis";
|
||||
import { ZodError } from "zod";
|
||||
import type {
|
||||
TaskState,
|
||||
AgentState,
|
||||
@@ -8,6 +9,7 @@ import type {
|
||||
EventHandler,
|
||||
} from "./types";
|
||||
import { isValidTaskTransition, isValidAgentTransition } from "./types";
|
||||
import { TaskStateSchema, AgentStateSchema, OrchestratorEventSchema } from "./schemas";
|
||||
|
||||
export interface ValkeyClientConfig {
|
||||
host: string;
|
||||
@@ -24,6 +26,21 @@ export interface ValkeyClientConfig {
|
||||
*/
|
||||
export type EventErrorHandler = (error: Error, rawMessage: string, channel: string) => void;
|
||||
|
||||
/**
|
||||
* Error thrown when Redis data fails validation
|
||||
*/
|
||||
export class ValkeyValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly key: string,
|
||||
public readonly dataSnippet: string,
|
||||
public readonly validationError: ZodError
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ValkeyValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valkey client for state management and pub/sub
|
||||
*/
|
||||
@@ -66,7 +83,7 @@ export class ValkeyClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(data) as TaskState;
|
||||
return this.parseAndValidateTaskState(key, data);
|
||||
}
|
||||
|
||||
async setTaskState(state: TaskState): Promise<void> {
|
||||
@@ -119,7 +136,8 @@ export class ValkeyClient {
|
||||
for (const key of keys) {
|
||||
const data = await this.client.get(key);
|
||||
if (data) {
|
||||
tasks.push(JSON.parse(data) as TaskState);
|
||||
const task = this.parseAndValidateTaskState(key, data);
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +156,7 @@ export class ValkeyClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(data) as AgentState;
|
||||
return this.parseAndValidateAgentState(key, data);
|
||||
}
|
||||
|
||||
async setAgentState(state: AgentState): Promise<void> {
|
||||
@@ -190,7 +208,8 @@ export class ValkeyClient {
|
||||
for (const key of keys) {
|
||||
const data = await this.client.get(key);
|
||||
if (data) {
|
||||
agents.push(JSON.parse(data) as AgentState);
|
||||
const agent = this.parseAndValidateAgentState(key, data);
|
||||
agents.push(agent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,17 +230,26 @@ export class ValkeyClient {
|
||||
|
||||
this.subscriber.on("message", (channel: string, message: string) => {
|
||||
try {
|
||||
const event = JSON.parse(message) as OrchestratorEvent;
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
const event = OrchestratorEventSchema.parse(parsed);
|
||||
void handler(event);
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Log the error
|
||||
// Log the error with context
|
||||
if (this.logger) {
|
||||
this.logger.error(
|
||||
`Failed to parse event from channel ${channel}: ${errorObj.message}`,
|
||||
errorObj
|
||||
);
|
||||
const snippet = message.length > 100 ? `${message.substring(0, 100)}...` : message;
|
||||
if (error instanceof ZodError) {
|
||||
this.logger.error(
|
||||
`Failed to validate event from channel ${channel}: ${errorObj.message} (data: ${snippet})`,
|
||||
errorObj
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to parse event from channel ${channel}: ${errorObj.message}`,
|
||||
errorObj
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke error handler if provided
|
||||
@@ -262,4 +290,56 @@ export class ValkeyClient {
|
||||
private getAgentKey(agentId: string): string {
|
||||
return `orchestrator:agent:${agentId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate task state data from Redis
|
||||
* @throws ValkeyValidationError if data is invalid
|
||||
*/
|
||||
private parseAndValidateTaskState(key: string, data: string): TaskState {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
return TaskStateSchema.parse(parsed);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const snippet = data.length > 100 ? `${data.substring(0, 100)}...` : data;
|
||||
const validationError = new ValkeyValidationError(
|
||||
`Invalid task state data at key ${key}: ${error.message}`,
|
||||
key,
|
||||
snippet,
|
||||
error
|
||||
);
|
||||
if (this.logger) {
|
||||
this.logger.error(validationError.message, validationError);
|
||||
}
|
||||
throw validationError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate agent state data from Redis
|
||||
* @throws ValkeyValidationError if data is invalid
|
||||
*/
|
||||
private parseAndValidateAgentState(key: string, data: string): AgentState {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
return AgentStateSchema.parse(parsed);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const snippet = data.length > 100 ? `${data.substring(0, 100)}...` : data;
|
||||
const validationError = new ValkeyValidationError(
|
||||
`Invalid agent state data at key ${key}: ${error.message}`,
|
||||
key,
|
||||
snippet,
|
||||
error
|
||||
);
|
||||
if (this.logger) {
|
||||
this.logger.error(validationError.message, validationError);
|
||||
}
|
||||
throw validationError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user