From 5d2307908624829051028ba9ebe3504fdf55730a Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Tue, 26 May 2026 14:03:58 +0200 Subject: [PATCH] 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) --- .../plugins/service-admin-http/routes-auth.ts | 25 ++- .../service-admin-http/routes-setup.ts | 19 +- server/src/plugins/service-api-http/index.ts | 185 ++++++------------ server/src/shared/api-schemas.ts | 164 ++++++++++++++++ 4 files changed, 252 insertions(+), 141 deletions(-) create mode 100644 server/src/shared/api-schemas.ts diff --git a/server/src/plugins/service-admin-http/routes-auth.ts b/server/src/plugins/service-admin-http/routes-auth.ts index 342e439..91f44fd 100644 --- a/server/src/plugins/service-admin-http/routes-auth.ts +++ b/server/src/plugins/service-admin-http/routes-auth.ts @@ -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." })); } diff --git a/server/src/plugins/service-admin-http/routes-setup.ts b/server/src/plugins/service-admin-http/routes-setup.ts index 50bf8fa..30d823e 100644 --- a/server/src/plugins/service-admin-http/routes-setup.ts +++ b/server/src/plugins/service-admin-http/routes-setup.ts @@ -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 3–64 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 })); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 36f2e95..969fbb7 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -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>; - 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 = { 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(); 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; - }>(event); - - if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" }); + const body = validateBody(EventBody, await readBody(event)); + const payload = (body.payload ?? {}) as Record; 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; 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, + 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, diff --git a/server/src/shared/api-schemas.ts b/server/src/shared/api-schemas.ts new file mode 100644 index 0000000..dc63f68 --- /dev/null +++ b/server/src/shared/api-schemas.ts @@ -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(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; +}