diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963409fc8a..1e8810940b 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -40,6 +40,7 @@ export const useMonitorForm = ({ matchMethod: data?.matchMethod || "", expectedValue: data?.expectedValue || "", jsonPath: data?.jsonPath || "", + customUserAgent: data?.customUserAgent || "", }; break; case "ping": diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 4b0768d79b..a9672cf0e6 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -40,6 +40,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => : 80, }, checkTTL: data?.checkTTL ?? 30, + defaultUserAgent: data?.defaultUserAgent || "", pagespeedApiKey: "", systemEmailPassword: "", }; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 15b76eab36..988187984e 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -795,6 +795,40 @@ const CreateMonitorPage = () => { /> )} + {watchedType === "http" && ( + ( + { + field.onChange( + e.target.value + .replace(/[<>]/g, "") + .replace(/(?:javascript|data|vbscript):/gi, "") + .replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only + .slice(0, 500) + ); + }} + /> + )} + /> + } + /> + )} + {watchedType === "http" && ( { } /> + {/* Default User-Agent - Admin Only */} + {isAdmin && ( + ( + { + field.onChange( + e.target.value + .replace(/[<>]/g, "") + .replace(/(?:javascript|data|vbscript):/gi, "") + .replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only + .slice(0, 500) + ); + }} + /> + )} + /> + } + /> + )} + {/* Clear All Stats */} {isAdmin && ( (val.trim() === "" ? null : val.trim())) + .nullable() + .optional(), }); // Ping monitor schema diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index cf122f69db..e0cf1ef9dd 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -12,6 +12,15 @@ export const settingsSchema = z.object({ systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), showURL: z.boolean().optional(), + defaultUserAgent: z + .string() + .max(500) + .regex( + /^[\t\x20-\x7E\x80-\xFF]*$/, + "Only printable characters, spaces, and tabs are allowed (RFC 7230)" + ) + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), checkTTL: z .number() .int() diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 83ca4fa332..ac168d5ccc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -527,6 +527,14 @@ }, "title": "TLS/SSL settings" }, + "userAgent": { + "title": "Custom User-Agent", + "description": "Override the User-Agent header sent with HTTP requests for this monitor. Useful for identifying Checkmate in WAF logs. Leave blank to use the global default.", + "option": { + "label": "User-Agent", + "placeholder": "Checkmate/X.X (uptime monitor)" + } + }, "incidents": { "description": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", "option": { @@ -1051,6 +1059,14 @@ } } }, + "userAgent": { + "title": "Default User-Agent", + "description": "Set a default User-Agent header sent with all HTTP uptime monitor requests. Individual monitors can override this. Useful for identifying Checkmate in WAF logs and whitelists.", + "option": { + "label": "Default User-Agent", + "placeholder": "Checkmate/X.X (uptime monitor)" + } + }, "stats": { "title": "Monitor history", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.", diff --git a/server/src/db/migration/timescaledb/0022_add_user_agent_fields.ts b/server/src/db/migration/timescaledb/0022_add_user_agent_fields.ts new file mode 100644 index 0000000000..dd215158c1 --- /dev/null +++ b/server/src/db/migration/timescaledb/0022_add_user_agent_fields.ts @@ -0,0 +1,21 @@ +import type { Pool } from "pg"; + +export const addUserAgentFields = async (pool: Pool) => { + await pool.query(` + ALTER TABLE monitors + ADD COLUMN IF NOT EXISTS custom_user_agent TEXT; + + ALTER TABLE app_settings + ADD COLUMN IF NOT EXISTS default_user_agent TEXT; + `); +}; + +export const dropUserAgentFields = async (pool: Pool) => { + await pool.query(` + ALTER TABLE monitors + DROP COLUMN IF EXISTS custom_user_agent; + + ALTER TABLE app_settings + DROP COLUMN IF EXISTS default_user_agent; + `); +}; diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts index 14084ebf3d..6ed4f5e1eb 100644 --- a/server/src/db/migration/timescaledb/index.ts +++ b/server/src/db/migration/timescaledb/index.ts @@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; +import { addUserAgentFields, dropUserAgentFields } from "./0022_add_user_agent_fields.js"; const SERVICE_NAME = "TimescaleDB Migrations"; @@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [ { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, + { name: "0022_add_user_agent_fields", up: addUserAgentFields, down: dropUserAgentFields }, ]; const ensureMigrationsTable = async (pool: Pool) => { diff --git a/server/src/db/models/AppSettings.ts b/server/src/db/models/AppSettings.ts index 9eeaa640aa..f46396c01d 100644 --- a/server/src/db/models/AppSettings.ts +++ b/server/src/db/models/AppSettings.ts @@ -36,6 +36,7 @@ const AppSettingsSchema = new Schema( systemEmailRequireTLS: { type: Boolean, default: false }, systemEmailRejectUnauthorized: { type: Boolean, default: true }, showURL: { type: Boolean, default: false }, + defaultUserAgent: { type: String }, singleton: { type: Boolean, required: true, unique: true, default: true }, version: { type: Number, default: 1 }, globalThresholds: { type: thresholdsSchema }, diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad6..7b4b0c178b 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -339,6 +339,9 @@ const MonitorSchema = new Schema( return value && value.trim() ? value.trim() : null; }, }, + customUserAgent: { + type: String, + }, geoCheckEnabled: { type: Boolean, default: false, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index b2d7594483..1ee359ef0e 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -387,6 +387,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { gameId: doc.gameId ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined, group: doc.group ?? null, + customUserAgent: doc.customUserAgent ?? undefined, recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], @@ -446,6 +447,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { gameId: doc.gameId ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined, group: doc.group ?? null, + customUserAgent: doc.customUserAgent ?? undefined, recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], diff --git a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts index a94d385042..5c42e3fd27 100644 --- a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts +++ b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts @@ -37,6 +37,7 @@ interface MonitorRow { game_id: string | null; grpc_service_name: string | null; monitor_group: string | null; + custom_user_agent: string | null; geo_check_enabled: boolean; geo_check_locations: GeoContinent[] | null; geo_check_interval_ms: number; @@ -49,7 +50,7 @@ const MONITOR_COLUMNS = `id, user_id, team_id, name, description, type, status, interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage, cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms, + game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms, created_at, updated_at`; export class TimescaleMonitorsRepository implements IMonitorsRepository { @@ -62,8 +63,8 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { interval_ms, is_active, status_window, status_window_size, status_window_threshold, cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34) + game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35) RETURNING ${MONITOR_COLUMNS}`, [ userId, @@ -97,6 +98,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { monitor.gameId ?? null, monitor.grpcServiceName ?? null, monitor.group ?? null, + monitor.customUserAgent ?? null, monitor.geoCheckEnabled ?? false, monitor.geoCheckLocations ?? [], monitor.geoCheckInterval ?? 300000, @@ -595,6 +597,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { ["gameId", "game_id"], ["grpcServiceName", "grpc_service_name"], ["group", "monitor_group"], + ["customUserAgent", "custom_user_agent"], ["geoCheckEnabled", "geo_check_enabled"], ["geoCheckLocations", "geo_check_locations"], ["geoCheckInterval", "geo_check_interval_ms"], @@ -1034,6 +1037,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { gameId: row.game_id ?? undefined, grpcServiceName: row.grpc_service_name ?? undefined, group: row.monitor_group, + customUserAgent: row.custom_user_agent ?? undefined, geoCheckEnabled: row.geo_check_enabled, geoCheckLocations: row.geo_check_locations ?? [], geoCheckInterval: row.geo_check_interval_ms, diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts index 3117a94ea2..b2b604c4ce 100644 --- a/server/src/repositories/settings/MongoSettingsRepository.ts +++ b/server/src/repositories/settings/MongoSettingsRepository.ts @@ -38,6 +38,7 @@ class MongoSettingsRepository implements ISettingsRepository { systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false, systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true, showURL: doc.showURL ?? false, + defaultUserAgent: doc.defaultUserAgent ?? undefined, singleton: doc.singleton, version: doc.version ?? 1, globalThresholds: doc.globalThresholds ?? undefined, diff --git a/server/src/repositories/settings/TimescaleSettingsRepository.ts b/server/src/repositories/settings/TimescaleSettingsRepository.ts index 82038b23f4..7856db8bfa 100644 --- a/server/src/repositories/settings/TimescaleSettingsRepository.ts +++ b/server/src/repositories/settings/TimescaleSettingsRepository.ts @@ -21,6 +21,7 @@ interface SettingsRow { system_email_require_tls: boolean | null; system_email_reject_unauthorized: boolean | null; show_url: boolean | null; + default_user_agent: string | null; version: string | null; threshold_cpu_usage: number | null; threshold_memory_usage: number | null; @@ -34,7 +35,7 @@ const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key, system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, - show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, + show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, created_at, updated_at`; export class TimescaleSettingsRepository implements ISettingsRepository { @@ -46,8 +47,8 @@ export class TimescaleSettingsRepository implements ISettingsRepository { system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, - show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) RETURNING ${COLUMNS}`, [ settings.checkTTL ?? 30, @@ -67,6 +68,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository { settings.systemEmailRequireTLS ?? false, settings.systemEmailRejectUnauthorized ?? true, settings.showURL ?? false, + settings.defaultUserAgent ?? null, settings.version ?? 1, settings.globalThresholds?.cpu ?? null, settings.globalThresholds?.memory ?? null, @@ -113,6 +115,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository { ["systemEmailRequireTLS", "system_email_require_tls"], ["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"], ["showURL", "show_url"], + ["defaultUserAgent", "default_user_agent"], ["version", "version"], ]; @@ -197,6 +200,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository { systemEmailRequireTLS: row.system_email_require_tls ?? false, systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true, showURL: row.show_url ?? false, + defaultUserAgent: row.default_user_agent ?? undefined, singleton: true, version: Number(row.version ?? 1), globalThresholds: diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index b6908127b2..b13d10267c 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -132,7 +132,15 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { } // Step 2. Request monitor status - const status = await this.networkService.requestStatus(monitor); + // Resolve default user agent for HTTP monitors (cached, invalidated on settings update) + let effectiveMonitor: Monitor = monitor; + if (monitor.type === "http" && !monitor.customUserAgent) { + const defaultUserAgent = await this.settingsService.getDefaultUserAgent(); + if (defaultUserAgent) { + effectiveMonitor = { ...monitor, customUserAgent: defaultUserAgent }; + } + } + const status = await this.networkService.requestStatus(effectiveMonitor); if (!status) { throw new Error("No network response"); } diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index bbc0934f8f..37fc54254a 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -56,15 +56,30 @@ export class HttpProvider implements IStatusProvider { }; } + private sanitizeHeaderValue(value: string): string { + if (!value) return ""; + + return value + .replace(/[<>]/g, "") + .replace(/(?:javascript|data|vbscript):/gi, "") + .replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // allow only RFC 7230 header-safe characters + .trim() + .slice(0, 500); + } + async handle(monitor: Monitor): Promise> { - const { url, secret, jsonPath, ignoreTlsErrors } = monitor; + const { url, secret, jsonPath, ignoreTlsErrors, customUserAgent } = monitor; if (!url) { throw new Error("URL is required for HTTP monitor"); } + const headers: Record = {}; + if (secret) headers["Authorization"] = `Bearer ${secret}`; + if (customUserAgent) headers["User-Agent"] = this.sanitizeHeaderValue(customUserAgent); + const options: Record = { - headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, + headers: Object.keys(headers).length > 0 ? headers : undefined, }; options.agent = { diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index f2daff5506..1c41ac28a3 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -20,6 +20,7 @@ export interface ISettingsService { loadSettings(): EnvConfig; getSettings(): EnvConfig; getDBSettings(): Promise; + getDefaultUserAgent(): Promise; updateDbSettings(newSettings: SettingsUpdate): Promise; } @@ -27,6 +28,7 @@ export class SettingsService implements ISettingsService { static SERVICE_NAME = SERVICE_NAME; private settings: EnvConfig; private settingsRepository: ISettingsRepository | null = null; + private cachedDefaultUserAgent: string | null | undefined = undefined; constructor(env: ValidatedEnv) { this.settings = { @@ -70,8 +72,21 @@ export class SettingsService implements ISettingsService { return this.settingsRepository; } + getDefaultUserAgent = async (): Promise => { + if (this.cachedDefaultUserAgent !== undefined) { + return this.cachedDefaultUserAgent ?? undefined; + } + const settings = await this.getDBSettings(); + this.cachedDefaultUserAgent = settings.defaultUserAgent ?? null; + return this.cachedDefaultUserAgent ?? undefined; + }; + updateDbSettings = async (newSettings: SettingsUpdate) => { - return await this.getRepository().update(newSettings); + const updated = await this.getRepository().update(newSettings); + if ("defaultUserAgent" in newSettings) { + this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null; + } + return updated; }; getDBSettings = async () => { diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d78..8a551e1944 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -50,6 +50,7 @@ export interface Monitor { gameId?: string; grpcServiceName?: string; group: string | null; + customUserAgent?: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index bcedfbe892..348c433b11 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -31,6 +31,7 @@ export interface Settings { systemEmailRequireTLS: boolean; systemEmailRejectUnauthorized: boolean; showURL: boolean; + defaultUserAgent?: string; singleton: boolean; version: number; globalThresholds?: SettingsThresholds; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef2..f83f5b591f 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { booleanCoercion } from "./shared.js"; +import { booleanCoercion, httpHeaderValueSchema } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; @@ -75,6 +75,7 @@ export const createMonitorBodyValidation = z.object({ grpcServiceName: z.union([z.string(), z.literal("")]).default(""), selectedDisks: z.array(z.string()).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), @@ -104,6 +105,7 @@ export const editMonitorBodyValidation = z.object({ grpcServiceName: z.union([z.string(), z.literal("")]).optional(), selectedDisks: z.array(z.string()).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), @@ -157,6 +159,7 @@ const importedMonitorSchema = z.object({ gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).default(""), group: z.union([z.string().max(50).trim(), z.null()]).default(null), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index f2c4e4c94a..1f9ee3ead0 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -1,5 +1,6 @@ import { CHECK_TTL_SENTINEL } from "@/types/check.js"; import { z } from "zod"; +import { httpHeaderValueSchema } from "@/validation/shared.js"; //**************************************** // Settings Validations @@ -20,6 +21,7 @@ export const updateAppSettingsBodyValidation = z systemEmailTLSServername: z.string().nullable().optional(), showURL: z.boolean().optional(), + defaultUserAgent: httpHeaderValueSchema.nullable().optional(), systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), systemEmailIgnoreTLS: z.boolean().optional(), diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 28fd13c82a..8e11fbc3ea 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -21,6 +21,12 @@ export const booleanCoercion = z.preprocess((val) => { return val; // Let Zod validation handle invalid values }, z.boolean()); +// RFC 7230: validate HTTP header field-value characters +export const httpHeaderValueSchema = z + .string() + .max(500) + .regex(/^[\t\x20-\x7E\x80-\xFF]*$/, "Must contain only valid HTTP header characters (RFC 7230)"); + export const roleValidator = (allowedRoles: UserRole[]) => { return z.array(z.custom()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), { message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`,