fix(housekeeping): harden command dispatch boundaries

This commit is contained in:
Simo committed 2026-08-27 19:32:19 +02:00
1 parent 796d009c07
commit 00618fb2a3
8 files changed
+608 -30

No files matched your search

@@ -1,3 +1,4 @@
import { isIP } from "node:net";
import { z } from "zod";
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
import { satisfiesCapability } from "../capability-context";
@@ -18,12 +19,19 @@ import {
getHousekeepingCommand,
type HousekeepingCommand,
type HousekeepingCommandContext,
isHousekeepingCommandId,
} from "./registry";
const normalizedCommandId = z
.string()
.transform((value) => value.normalize("NFC").trim())
.pipe(z.string().min(1).max(160));
.pipe(
z
.string()
.min(1)
.max(160)
.refine(isHousekeepingCommandId, "invalid command id"),
);
const normalizedReason = z
.string()
.transform((value) => value.normalize("NFC").trim())
@@ -33,6 +41,34 @@ const commandRequestSchema = z.strictObject({
input: z.unknown(),
reason: normalizedReason.optional(),
});
const housekeepingErrorCodeSchema = z.enum([
"UNAUTHENTICATED",
"FORBIDDEN",
"VALIDATION",
"NOT_FOUND",
"CONFLICT",
"RATE_LIMITED",
"DEPENDENCY_UNAVAILABLE",
"TIMEOUT",
"INTERNAL",
]);
const housekeepingErrorSchema = z.strictObject({
code: housekeepingErrorCodeSchema,
messageKey: z.string().min(1).max(160),
fieldErrors: z.record(z.string(), z.array(z.string())).optional(),
});
const housekeepingResultSchema = z.discriminatedUnion("ok", [
z.strictObject({
ok: z.literal(true),
data: z.unknown(),
correlationId: z.string().min(1).max(160),
}),
z.strictObject({
ok: z.literal(false),
error: housekeepingErrorSchema,
correlationId: z.string().min(1).max(160),
}),
]);
export interface HousekeepingCommandRequest {
commandId: string;
@@ -52,14 +88,50 @@ export async function dispatchHousekeepingCommand(
dependencies: HousekeepingCommandDependencies,
): Promise<HousekeepingResult<unknown>> {
const correlationId = createCorrelationId();
const ipAddress = canonicalizeServerIp(dependencies.ipAddress);
if (ipAddress === undefined) {
return persistReturnedOutcome(
dependencies.audit,
createDispatchAuditEntry(
"server-context",
dependencies.context,
correlationId,
),
"failure",
mapUnknownError(new Error("invalid server IP"), correlationId),
);
}
if (!isPlainRecord(request)) {
return persistMalformedRequestOutcome(
dependencies,
ipAddress,
correlationId,
);
}
let parsedRequest: ReturnType<typeof commandRequestSchema.safeParse>;
try {
parsedRequest = commandRequestSchema.safeParse(request);
} catch (error) {
return mapUnknownError(error, correlationId);
return persistReturnedOutcome(
dependencies.audit,
createDispatchAuditEntry(
"request-envelope",
dependencies.context,
correlationId,
ipAddress,
),
"failure",
mapUnknownError(error, correlationId),
);
}
if (!parsedRequest.success) {
return fail("VALIDATION", "errors.housekeeping.validation", correlationId);
return persistMalformedRequestOutcome(
dependencies,
ipAddress,
correlationId,
);
}
const commandRequest = parsedRequest.data;
@@ -74,7 +146,8 @@ export async function dispatchHousekeepingCommand(
dependencies.audit,
createUnknownCommandAuditEntry(
commandRequest.commandId,
dependencies,
dependencies.context,
ipAddress,
correlationId,
),
"denied",
@@ -85,7 +158,7 @@ export async function dispatchHousekeepingCommand(
const auditEntry = createAuditEntry(
command,
dependencies.context,
dependencies.ipAddress,
ipAddress,
correlationId,
commandRequest.reason || undefined,
);
@@ -167,11 +240,7 @@ export async function dispatchHousekeepingCommand(
let allowed: boolean;
try {
allowed = await dependencies.rateLimit(
createRateLimitKey(
command.id,
dependencies.context,
dependencies.ipAddress,
),
createRateLimitKey(command.id, dependencies.context, ipAddress),
command.rateLimit.attempts,
command.rateLimit.windowMs,
);
@@ -203,11 +272,12 @@ export async function dispatchHousekeepingCommand(
const commandContext: HousekeepingCommandContext = {
capability: dependencies.context,
correlationId,
ipAddress: dependencies.ipAddress,
ipAddress,
};
let result: HousekeepingResult<unknown>;
let correlatedResult: HousekeepingResult<unknown>;
try {
result = await command.execute(commandContext, parsedInput.data);
const rawResult = await command.execute(commandContext, parsedInput.data);
correlatedResult = validateAndCorrelateResult(rawResult, correlationId);
} catch (error) {
return persistReturnedOutcome(
dependencies.audit,
@@ -217,7 +287,6 @@ export async function dispatchHousekeepingCommand(
);
}
const correlatedResult = withCorrelation(result, correlationId);
if (!correlatedResult.ok) {
return persistReturnedOutcome(
dependencies.audit,
@@ -251,17 +320,33 @@ function createAuditEntry(
};
}
function createDispatchAuditEntry(
target: "request-envelope" | "server-context",
context: HousekeepingCapabilityContext,
correlationId: string,
ipAddress?: string,
): AuditEntry {
return {
userId: context.actor.id,
action: "housekeeping.command.dispatch",
target,
correlationId,
...(ipAddress === undefined ? {} : { ipAddress }),
};
}
function createUnknownCommandAuditEntry(
commandId: string,
dependencies: HousekeepingCommandDependencies,
context: HousekeepingCapabilityContext,
ipAddress: string,
correlationId: string,
): AuditEntry {
return {
userId: dependencies.context.actor.id,
userId: context.actor.id,
action: "housekeeping.command.dispatch",
target: commandId,
correlationId,
ipAddress: dependencies.ipAddress,
ipAddress,
};
}
@@ -273,11 +358,56 @@ function createRateLimitKey(
return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`;
}
function withCorrelation<T>(
result: HousekeepingResult<T>,
function canonicalizeServerIp(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const candidate = value.trim();
const version = isIP(candidate);
if (version === 4) {
return candidate
.split(".")
.map((part) => String(Number(part)))
.join(".");
}
if (version === 6) {
try {
return new URL(`http://[${candidate}]/`).hostname.slice(1, -1);
} catch {
return undefined;
}
}
return undefined;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
try {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
} catch {
return false;
}
}
function validateAndCorrelateResult(
value: unknown,
correlationId: string,
): HousekeepingResult<T> {
return { ...result, correlationId };
): HousekeepingResult<unknown> {
if (!isPlainRecord(value) || !Object.hasOwn(value, "ok")) {
throw new Error("invalid housekeeping command result");
}
if (value.ok === true && !Object.hasOwn(value, "data")) {
throw new Error("invalid housekeeping command result");
}
if (value.ok === false && !Object.hasOwn(value, "error")) {
throw new Error("invalid housekeeping command result");
}
const parsed = housekeepingResultSchema.safeParse(value);
if (!parsed.success) {
throw new Error("invalid housekeeping command result");
}
return { ...parsed.data, correlationId };
}
function outcomeForFailure(
@@ -290,6 +420,24 @@ function outcomeForFailure(
: "failure";
}
function persistMalformedRequestOutcome(
dependencies: HousekeepingCommandDependencies,
ipAddress: string,
correlationId: string,
): Promise<HousekeepingResult<never>> {
return persistReturnedOutcome(
dependencies.audit,
createDispatchAuditEntry(
"request-envelope",
dependencies.context,
correlationId,
ipAddress,
),
"denied",
fail("VALIDATION", "errors.housekeeping.validation", correlationId),
);
}
async function persistReturnedOutcome<T>(
writer: HousekeepingAuditWriter,
entry: AuditEntry,