feat: add anyvali input validation to all external API endpoints

Create shared/api-schemas.ts with av.object schemas for:
- pair/initiate, pair/claim (pairing flow)
- kiosk/heartbeat (telemetry with displays, partitions, hwmon)
- kiosk/event (ONVIF/system events)
- kiosk/logs (batched log entries)
- firmware/applied, os/applied (update reports)
- auth/login, auth/totp, setup (admin auth)

Each endpoint now calls validateBody(Schema, body) which returns 400
on schema violation. All string fields have maxLength, numeric fields
have min/max ranges, arrays strip unknown keys. Rejects malformed
input before it reaches DB or business logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-26 14:03:58 +02:00
parent 515f7088cc
commit 54a89955ca
No known key found for this signature in database
4 changed files with 252 additions and 141 deletions

View file

@ -7,6 +7,7 @@ import type { AdminDeps } from "./index.js";
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
import { audit } from "../../shared/audit.js";
import { createRateLimiter } from "../../shared/rate-limit.js";
import { LoginBody, TotpBody, validateBody } from "../../shared/api-schemas.js";
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
@ -37,13 +38,14 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
});
}
const body = await readBody<{ username?: string; password?: string }>(event);
const username = (body?.username ?? "").trim();
const password = body?.password ?? "";
if (!username || !password) {
return htmlPage(LoginPage({ error: "Username and password required.", username }));
let body: { username: string; password: string };
try {
body = validateBody(LoginBody, await readBody(event));
} catch {
return htmlPage(LoginPage({ error: "Username and password required.", username: "" }));
}
const username = body.username.trim();
const password = body.password;
const user = await deps.repo.getUserByUsername(username);
if (!user || !user.is_active) {
@ -128,10 +130,15 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
const body = await readBody<{ code?: string }>(event);
const code = (body?.code ?? "").trim().replace(/\s/g, "");
let totpBody: { code: string };
try {
totpBody = validateBody(TotpBody, await readBody(event));
} catch {
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
}
const code = totpBody.code.trim().replace(/\s/g, "");
if (!code || code.length !== 6) {
if (code.length !== 6) {
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
}

View file

@ -5,6 +5,7 @@ import { type H3, readBody } from "h3";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js";
import { SetupPage } from "../../web-templates/auth-pages.js";
import { SetupBody, validateBody } from "../../shared/api-schemas.js";
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
app.get("/setup", async () => {
@ -19,19 +20,19 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
const body = await readBody<{ username?: string; password?: string }>(event);
const username = (body?.username ?? "").trim();
const password = body?.password ?? "";
let body: { username: string; password: string };
try {
body = validateBody(SetupBody, await readBody(event));
} catch {
return htmlPage(SetupPage({ error: "Username (3-64 chars) and password (12+ chars) required.", username: "" }));
}
const username = body.username.trim();
const password = body.password;
const errors: string[] = [];
if (!username || username.length < 3 || username.length > 64) {
errors.push("Username must be 364 characters.");
} else if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
errors.push("Username may only contain letters, digits, underscore, or hyphen.");
}
if (password.length < 12) {
errors.push("Password must be at least 12 characters.");
}
if (errors.length > 0) {
return htmlPage(SetupPage({ error: errors.join(" "), username }));

View file

@ -31,6 +31,10 @@ import { createHash } from "node:crypto";
import type { AuthApi } from "../../shared/auth.js";
import type { SecretsApi } from "../../shared/secrets.js";
import type { FirmwareChannel } from "../../shared/types.js";
import {
PairInitiateBody, PairClaimBody, HeartbeatBody, EventBody,
KioskLogsBody, FirmwareAppliedBody, OsAppliedBody, validateBody,
} from "../../shared/api-schemas.js";
// ---- Config -----------------------------------------------------------------
@ -292,18 +296,13 @@ function registerPairingRoutes(
throw createError({ statusCode: 429, statusMessage: "rate limited" });
}
const body = await readBody<{
proposed_name?: string;
hardware_model?: string;
capabilities?: string[];
managed_image?: boolean;
}>(event);
const body = validateBody(PairInitiateBody, await readBody(event));
const result = await initiatePairing(repo, {
proposedName: body?.proposed_name ?? null,
hardwareModel: body?.hardware_model ?? null,
capabilities: body?.capabilities ?? [],
managedImage: body?.managed_image === true,
proposedName: body.proposed_name || null,
hardwareModel: body.hardware_model || null,
capabilities: body.capabilities,
managedImage: body.managed_image,
codeTtlSeconds: codeTtl,
});
@ -319,9 +318,8 @@ function registerPairingRoutes(
throw createError({ statusCode: 429, statusMessage: "rate limited" });
}
const body = await readBody<{ code?: string }>(event);
const code = (body?.code ?? "").trim().toUpperCase();
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
const body = validateBody(PairClaimBody, await readBody(event));
const code = body.code.trim().toUpperCase();
const reqObs = event.context.obs!;
const result = await claimPairing(repo, code, reqObs);
@ -459,44 +457,7 @@ function registerKioskRoutes(
if (!kiosk) return { bf_kiosk_deleted: true };
event.context.obs?.log.info("heartbeat from kiosk {id}", { id: String(kiosk.id) });
const body = await readBody<{
bundle_version?: string;
kiosk_app_version?: string;
os_version?: string;
displays?: Array<{
index?: number;
name: string;
width_px: number;
height_px: number;
power_state?: "awake" | "standby" | "unknown";
}>;
cpu_temp_c?: number | null;
cpu_load_percent?: number | null;
fan_rpm?: number | null;
fan_pwm?: number | null;
memory_total_mb?: number | null;
memory_used_mb?: number | null;
disk_total_mb?: number | null;
disk_free_mb?: number | null;
disk_used_percent?: number | null;
local_key?: string | null;
local_port?: number | null;
reported_hostname?: string | null;
network_interfaces?: Array<Record<string, unknown>>;
partitions?: Array<{
device: string;
mountpoint: string;
total_mb: number;
used_mb: number;
free_mb: number;
used_percent: number;
}>;
// Managed-image kiosk echoes back the version it last applied, and the
// last apply error (if any). Server uses these to decide whether to
// include pending_config in the response.
managed_config_applied_version?: number;
managed_config_error?: string | null;
}>(event);
const body = validateBody(HeartbeatBody, await readBody(event));
// Capture the kiosk's LAN-side IP from the heartbeat connection so admin
// can render a copy-paste URL even when the kiosk has no DNS name.
@ -505,26 +466,26 @@ function registerKioskRoutes(
?? null;
await repo.touchKiosk(kiosk.id, {
bundle_version: body?.bundle_version ?? null,
kiosk_app_version: body?.kiosk_app_version ?? null,
os_version: body?.os_version ?? null,
cpu_temp_c: body?.cpu_temp_c ?? null,
cpu_load_percent: body?.cpu_load_percent ?? null,
fan_rpm: body?.fan_rpm ?? null,
fan_pwm: body?.fan_pwm ?? null,
memory_total_mb: body?.memory_total_mb ?? null,
memory_used_mb: body?.memory_used_mb ?? null,
disk_total_mb: body?.disk_total_mb ?? null,
disk_free_mb: body?.disk_free_mb ?? null,
disk_used_percent: body?.disk_used_percent ?? null,
local_key: body?.local_key ?? null,
local_port: body?.local_port ?? null,
bundle_version: body.bundle_version ?? null,
kiosk_app_version: body.kiosk_app_version ?? null,
os_version: body.os_version ?? null,
cpu_temp_c: body.cpu_temp_c ?? null,
cpu_load_percent: body.cpu_load_percent ?? null,
fan_rpm: body.fan_rpm ?? null,
fan_pwm: body.fan_pwm ?? null,
memory_total_mb: body.memory_total_mb ?? null,
memory_used_mb: body.memory_used_mb ?? null,
disk_total_mb: body.disk_total_mb ?? null,
disk_free_mb: body.disk_free_mb ?? null,
disk_used_percent: body.disk_used_percent ?? null,
local_key: body.local_key ?? null,
local_port: body.local_port ?? null,
local_last_ip: remoteIp,
reported_hostname: body?.reported_hostname ?? null,
network_interfaces_json: Array.isArray(body?.network_interfaces)
reported_hostname: body.reported_hostname ?? null,
network_interfaces_json: Array.isArray(body.network_interfaces)
? JSON.stringify(body.network_interfaces)
: null,
partitions_json: Array.isArray(body?.partitions)
partitions_json: Array.isArray(body.partitions)
? JSON.stringify(body.partitions)
: null,
});
@ -534,7 +495,7 @@ function registerKioskRoutes(
// successful apply (kiosk omits it). verifyKioskKey returns just {id};
// re-read the full row to check the managed_image flag.
const kioskFull = await repo.getKioskById(kiosk.id);
if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") {
if (kioskFull?.managed_image && typeof body.managed_config_applied_version === "number") {
const patch: Record<string, unknown> = {
managed_config_applied_version: body.managed_config_applied_version,
managed_config_applied_at: new Date().toISOString(),
@ -547,24 +508,24 @@ function registerKioskRoutes(
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
mqtt.publishTelemetry(kiosk.id, {
kiosk_app_version: body?.kiosk_app_version,
bundle_version: body?.bundle_version,
cpu_temp_c: body?.cpu_temp_c,
cpu_load_percent: body?.cpu_load_percent,
fan_rpm: body?.fan_rpm,
fan_pwm: body?.fan_pwm,
memory_total_mb: body?.memory_total_mb,
memory_used_mb: body?.memory_used_mb,
disk_total_mb: body?.disk_total_mb,
disk_free_mb: body?.disk_free_mb,
disk_used_percent: body?.disk_used_percent,
kiosk_app_version: body.kiosk_app_version,
bundle_version: body.bundle_version,
cpu_temp_c: body.cpu_temp_c,
cpu_load_percent: body.cpu_load_percent,
fan_rpm: body.fan_rpm,
fan_pwm: body.fan_pwm,
memory_total_mb: body.memory_total_mb,
memory_used_mb: body.memory_used_mb,
disk_total_mb: body.disk_total_mb,
disk_free_mb: body.disk_free_mb,
disk_used_percent: body.disk_used_percent,
ip: remoteIp,
reported_hostname: body?.reported_hostname,
network_interfaces: body?.network_interfaces,
reported_hostname: body.reported_hostname,
network_interfaces: body.network_interfaces,
});
// Sync displays reported by the kiosk
if (Array.isArray(body?.displays)) {
if (Array.isArray(body.displays)) {
const existing = await repo.listDisplaysForKiosk(kiosk.id);
const seenDisplayIds = new Set<string>();
for (const [position, reported] of body.displays.entries()) {
@ -665,20 +626,11 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = await readBody<{
topic: string;
source_type?: string;
camera_id?: string;
property_op?: string;
payload?: Record<string, unknown>;
}>(event);
if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" });
const body = validateBody(EventBody, await readBody(event));
const payload = (body.payload ?? {}) as Record<string, unknown>;
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
// Dedup: Hikvision cameras send duplicate ONVIF events within ~1s.
// Key = kiosk_id:camera_id:topic:source_keys_hash. Window = 2s.
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(body.payload?.["source"] ?? "")}`;
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(payload["source"] ?? "")}`;
const now = Date.now();
if (eventDedupCache.has(dedupKey)) {
const lastSeen = eventDedupCache.get(dedupKey)!;
@ -703,7 +655,7 @@ function registerKioskRoutes(
source_type: (body.source_type as any) ?? "system",
topic: body.topic,
property_op: body.property_op ?? null,
payload: body.payload ?? {},
payload,
forwarded_to_nodered: false,
});
} catch (err: any) {
@ -714,7 +666,7 @@ function registerKioskRoutes(
source_type: (body.source_type as any) ?? "system",
topic: body.topic,
property_op: body.property_op ?? null,
payload: body.payload ?? {},
payload,
forwarded_to_nodered: false,
});
} else {
@ -725,8 +677,8 @@ function registerKioskRoutes(
// Side-effect: persist active layout per display so the admin UI can
// surface "currently showing X" without having to query event_log.
if (body.topic === "layout.changed") {
const displayId = String(body.payload?.["display_id"] ?? "");
const layoutId = String(body.payload?.["layout_id"] ?? "");
const displayId = String(payload["display_id"] ?? "");
const layoutId = String(payload["layout_id"] ?? "");
if (displayId && layoutId) {
try {
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
@ -792,26 +744,19 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = await readBody<{
entries?: Array<{ level?: string; message?: string; context?: Record<string, unknown>; logged_at?: string }>;
}>(event);
const raw = body?.entries;
if (!Array.isArray(raw) || raw.length === 0) {
const body = validateBody(KioskLogsBody, await readBody(event));
if (body.entries.length === 0) {
throw createError({ statusCode: 400, statusMessage: "entries array required" });
}
if (raw.length > 100) {
throw createError({ statusCode: 400, statusMessage: "max 100 entries per batch" });
}
const validLevels = new Set(["debug", "info", "warn", "error"]);
const entries = raw
.filter((e) => e.message && typeof e.message === "string")
.map((e) => ({
level: (validLevels.has(e.level ?? "") ? e.level! : "info") as "debug" | "info" | "warn" | "error",
message: e.message!,
context: e.context ?? {},
logged_at: e.logged_at,
const entries = body.entries
.filter((e: any) => e.message.length > 0)
.map((e: any) => ({
level: (validLevels.has(e.level) ? e.level : "info") as "debug" | "info" | "warn" | "error",
message: String(e.message),
context: (e.context ?? {}) as Record<string, unknown>,
logged_at: e.logged_at as string | undefined,
}));
const count = await repo.insertKioskLogs(kiosk.id, entries);
@ -935,10 +880,7 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = await readBody<{ version: string; error?: string }>(event);
if (!body?.version) {
throw createError({ statusCode: 400, statusMessage: "version required" });
}
const body = validateBody(FirmwareAppliedBody, await readBody(event));
await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
await repo.insertEvent({
source_kiosk_id: kiosk.id,
@ -1079,10 +1021,7 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
const body = await readBody<{ version: string; error?: string }>(event);
if (!body?.version) {
throw createError({ statusCode: 400, statusMessage: "version required" });
}
const body = validateBody(OsAppliedBody, await readBody(event));
await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
await repo.insertEvent({
source_kiosk_id: kiosk.id,

View file

@ -0,0 +1,164 @@
/**
* Anyvali input schemas for all external-facing API endpoints.
* Applied via validateBody() / validateQuery() helpers.
*/
import * as av from "@anyvali/js";
// ---- Kiosk API (service-api-http) -------------------------------------------
export const PairInitiateBody = av.object(
{
proposed_name: av.string().maxLength(128).default(""),
hardware_model: av.string().maxLength(128).default(""),
capabilities: av.array(av.string().maxLength(64)).default([]),
managed_image: av.bool().default(false),
},
{ unknownKeys: "strip" },
);
export const PairClaimBody = av.object(
{
code: av.string().minLength(1).maxLength(16),
},
{ unknownKeys: "strip" },
);
const HeartbeatDisplay = av.object(
{
index: av.int().min(0).max(32).default(0),
name: av.string().maxLength(128).default(""),
width_px: av.int().min(0).max(16384).default(0),
height_px: av.int().min(0).max(16384).default(0),
power_state: av.string().maxLength(16).default("unknown"),
},
{ unknownKeys: "strip" },
);
const HeartbeatPartition = av.object(
{
device: av.string().maxLength(128).default(""),
mountpoint: av.string().maxLength(256).default(""),
total_mb: av.int().min(0).default(0),
used_mb: av.int().min(0).default(0),
free_mb: av.int().min(0).default(0),
used_percent: av.number().min(0).max(100).default(0),
},
{ unknownKeys: "strip" },
);
export const HeartbeatBody = av.object(
{
bundle_version: av.string().maxLength(128).default(""),
kiosk_app_version: av.string().maxLength(64).default(""),
os_version: av.string().maxLength(64).default(""),
displays: av.array(HeartbeatDisplay).default([]),
cpu_temp_c: av.nullable(av.number().min(-40).max(150)).default(null),
cpu_load_percent: av.nullable(av.number().min(0).max(100)).default(null),
fan_rpm: av.nullable(av.int().min(0).max(50000)).default(null),
fan_pwm: av.nullable(av.int().min(0).max(255)).default(null),
memory_total_mb: av.nullable(av.int().min(0)).default(null),
memory_used_mb: av.nullable(av.int().min(0)).default(null),
disk_total_mb: av.nullable(av.int().min(0)).default(null),
disk_free_mb: av.nullable(av.int().min(0)).default(null),
disk_used_percent: av.nullable(av.number().min(0).max(100)).default(null),
local_key: av.nullable(av.string().maxLength(256)).default(null),
local_port: av.nullable(av.int().min(1).max(65535)).default(null),
reported_hostname: av.nullable(av.string().maxLength(256)).default(null),
network_interfaces: av.array(av.any()).default([]),
partitions: av.array(HeartbeatPartition).default([]),
managed_config_applied_version: av.optional(av.int().min(0)),
managed_config_error: av.optional(av.nullable(av.string().maxLength(4096))),
onvif_subscriptions: av.optional(av.any()),
},
{ unknownKeys: "strip" },
);
export const EventBody = av.object(
{
topic: av.string().minLength(1).maxLength(512),
source_type: av.string().maxLength(32).default("system"),
camera_id: av.nullable(av.string().maxLength(64)).default(null),
property_op: av.nullable(av.string().maxLength(32)).default(null),
payload: av.any().default({}),
},
{ unknownKeys: "strip" },
);
const KioskLogEntry = av.object(
{
level: av.string().maxLength(16).default("info"),
message: av.string().maxLength(4096).default(""),
context: av.any().default({}),
logged_at: av.optional(av.string().maxLength(64)),
},
{ unknownKeys: "strip" },
);
export const KioskLogsBody = av.object(
{
entries: av.array(KioskLogEntry).default([]),
},
{ unknownKeys: "strip" },
);
export const FirmwareAppliedBody = av.object(
{
version: av.string().minLength(1).maxLength(64),
error: av.optional(av.string().maxLength(4096)),
},
{ unknownKeys: "strip" },
);
export const OsAppliedBody = av.object(
{
version: av.string().minLength(1).maxLength(64),
error: av.optional(av.string().maxLength(4096)),
},
{ unknownKeys: "strip" },
);
// ---- Auth (routes-auth, routes-setup) ----------------------------------------
export const LoginBody = av.object(
{
username: av.string().minLength(1).maxLength(128),
password: av.string().minLength(1).maxLength(1024),
},
{ unknownKeys: "strip" },
);
export const TotpBody = av.object(
{
code: av.string().minLength(1).maxLength(16),
},
{ unknownKeys: "strip" },
);
export const SetupBody = av.object(
{
username: av.string().minLength(3).maxLength(64),
password: av.string().minLength(12).maxLength(1024),
},
{ unknownKeys: "strip" },
);
export const PasswordChangeBody = av.object(
{
current_password: av.string().minLength(1).maxLength(1024),
new_password: av.string().minLength(12).maxLength(1024),
},
{ unknownKeys: "strip" },
);
// ---- Helper -----------------------------------------------------------------
export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T {
const result = schema.safeParse(raw);
if (!result.success) {
const msg = typeof result.error === "object" && result.error && "message" in result.error
? String((result.error as any).message)
: "invalid request body";
throw Object.assign(new Error(msg), { status: 400, statusText: "Bad Request" });
}
return result.data as T;
}