From babe5ae1770f5d4d2f4c82996b695b58d1736165 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Fri, 27 Mar 2026 08:48:16 -0400 Subject: [PATCH 1/6] feat: adding custom/default user agent support for http monitors --- client/src/Hooks/useMonitorForm.ts | 1 + client/src/Hooks/useSettingsForm.ts | 1 + client/src/Pages/CreateMonitor/index.tsx | 25 +++++++++++++++++++ client/src/Pages/Settings/index.tsx | 24 ++++++++++++++++++ client/src/Types/Monitor.ts | 1 + client/src/Types/Settings.ts | 1 + client/src/Validation/monitor.ts | 1 + client/src/Validation/settings.ts | 5 ++++ client/src/locales/en.json | 16 ++++++++++++ server/src/config/services.ts | 2 +- server/src/db/models/AppSettings.ts | 1 + server/src/db/models/Monitor.ts | 3 +++ .../monitors/MongoMonitorsRepository.ts | 2 ++ .../settings/MongoSettingsRepository.ts | 1 + .../infrastructure/network/HttpProvider.ts | 18 ++++++++++--- server/src/types/monitor.ts | 1 + server/src/types/settings.ts | 1 + server/src/validation/monitorValidation.ts | 3 +++ server/src/validation/settingsValidation.ts | 1 + 19 files changed, 104 insertions(+), 4 deletions(-) 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..86aac87416 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -795,6 +795,31 @@ const CreateMonitorPage = () => { /> )} + {watchedType === "http" && ( + ( + + )} + /> + } + /> + )} + {watchedType === "http" && ( { } /> + {/* Default User-Agent - Admin Only */} + {isAdmin && ( + ( + + )} + /> + } + /> + )} + {/* Clear All Stats */} {isAdmin && ( (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..6f0574dffd 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; your-instance)" + } + }, "stats": { "title": "Monitor history", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 8ed72862a9..19c6b4ee7d 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -248,7 +248,7 @@ export const initializeServices = async ({ // Network providers const pingProvider = new PingProvider(ping); - const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); + const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath), settingsService); const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); const hardwareProvider = new HardwareProvider(httpProvider); const dockerProvider = new DockerProvider(logger, Docker); 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/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/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index bbc0934f8f..02e1f0e82b 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -7,13 +7,15 @@ import { Agent as HttpsAgent } from "https"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; import CacheableLookup from "cacheable-lookup"; +import { ISettingsService } from "@/service/system/settingsService.js"; export class HttpProvider implements IStatusProvider { readonly type = "http"; constructor( private got: Got, - private advancedMatcher: IAdvancedMatcher + private advancedMatcher: IAdvancedMatcher, + private settingsService: ISettingsService ) { const cacheable = new CacheableLookup({ maxTtl: 300, errorTtl: 30 }); this.got = got.extend({ @@ -57,14 +59,24 @@ export class HttpProvider implements IStatusProvider { } 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"); } + let userAgent: string | undefined = customUserAgent; + if (!userAgent && monitor.type === "http") { + const dbSettings = await this.settingsService.getDBSettings(); + userAgent = dbSettings?.defaultUserAgent ?? undefined; + } + + const headers: Record = {}; + if (secret) headers["Authorization"] = `Bearer ${secret}`; + if (userAgent) headers["User-Agent"] = userAgent; + 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/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d78..cbe78aa4ab 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; 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..521b5f72bf 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -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: z.string().max(500).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: z.string().max(500).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: z.string().max(500).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..234037c8a8 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -20,6 +20,7 @@ export const updateAppSettingsBodyValidation = z systemEmailTLSServername: z.string().nullable().optional(), showURL: z.boolean().optional(), + defaultUserAgent: z.string().max(500).nullable().optional(), systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), systemEmailIgnoreTLS: z.boolean().optional(), From 023cf5782d8d4833ace89cc7fe4ba891082640d2 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Wed, 1 Apr 2026 16:52:55 -0400 Subject: [PATCH 2/6] fix: add caching for defaultUA db call and code fixes --- .claude/settings.local.json | 11 +++++++++ client/src/Pages/CreateMonitor/index.tsx | 9 ++++++++ client/src/Pages/Settings/index.tsx | 9 ++++++++ client/src/Validation/monitor.ts | 9 +++++++- client/src/Validation/settings.ts | 4 ++++ client/src/locales/en.json | 2 +- server/src/config/services.ts | 2 +- .../SuperSimpleQueueHelper.ts | 10 +++++++- .../infrastructure/network/HttpProvider.ts | 23 +++++++++++-------- server/src/service/system/settingsService.ts | 12 ++++++++++ server/src/validation/monitorValidation.ts | 8 +++---- server/src/validation/settingsValidation.ts | 3 ++- server/src/validation/shared.ts | 6 +++++ 13 files changed, 89 insertions(+), 19 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..9ad155edd3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr:*)", + "Bash(gh api:*)", + "WebFetch(domain:github.com)", + "Bash(xargs grep:*)", + "Bash(npx tsc:*)" + ] + } +} diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 86aac87416..a91f81b520 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -813,6 +813,15 @@ const CreateMonitorPage = () => { fullWidth error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} + onChange={(e) => { + field.onChange( + e.target.value + .replace(/[<>]/g, "") + .replace(/javascript:/gi, "") + .replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only + .slice(0, 500) + ); + }} /> )} /> diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 0b2c348900..6b09f98e3e 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -442,6 +442,15 @@ export const SettingsPage = () => { placeholder={t("pages.settings.form.userAgent.option.placeholder")} error={!!fieldState.error} helperText={fieldState.error?.message} + onChange={(e) => { + field.onChange( + e.target.value + .replace(/[<>]/g, "") + .replace(/javascript:/gi, "") + .replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only + .slice(0, 500) + ); + }} /> )} /> diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 1c575818e5..c9ab10afc5 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -38,7 +38,14 @@ const httpSchema = baseSchema.extend({ matchMethod: z.enum(["equal", "include", "regex", ""]).optional(), expectedValue: z.string().optional(), jsonPath: z.string().optional(), - customUserAgent: z.string().max(500).optional(), + customUserAgent: z + .string() + .max(500) + .regex( + /^[\t\x20-\x7E\x80-\xFF]*$/, + "Only printable characters, spaces, and tabs are allowed (RFC 7230 §3.2.6)" + ) + .optional(), }); // Ping monitor schema diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index 4615b07f90..e0cf1ef9dd 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -15,6 +15,10 @@ export const settingsSchema = z.object({ 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 diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 6f0574dffd..ac168d5ccc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1064,7 +1064,7 @@ "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; your-instance)" + "placeholder": "Checkmate/X.X (uptime monitor)" } }, "stats": { diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 19c6b4ee7d..8ed72862a9 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -248,7 +248,7 @@ export const initializeServices = async ({ // Network providers const pingProvider = new PingProvider(ping); - const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath), settingsService); + const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); const hardwareProvider = new HardwareProvider(httpProvider); const dockerProvider = new DockerProvider(logger, Docker); 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 02e1f0e82b..89b2938dd5 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -7,15 +7,13 @@ import { Agent as HttpsAgent } from "https"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; import CacheableLookup from "cacheable-lookup"; -import { ISettingsService } from "@/service/system/settingsService.js"; export class HttpProvider implements IStatusProvider { readonly type = "http"; constructor( private got: Got, - private advancedMatcher: IAdvancedMatcher, - private settingsService: ISettingsService + private advancedMatcher: IAdvancedMatcher ) { const cacheable = new CacheableLookup({ maxTtl: 300, errorTtl: 30 }); this.got = got.extend({ @@ -58,6 +56,17 @@ export class HttpProvider implements IStatusProvider { }; } + private sanitizeHeaderValue(value: string): string { + if (!value) return ""; + + return value + .replace(/[<>]/g, "") + .replace(/javascript:/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, customUserAgent } = monitor; @@ -65,15 +74,9 @@ export class HttpProvider implements IStatusProvider { throw new Error("URL is required for HTTP monitor"); } - let userAgent: string | undefined = customUserAgent; - if (!userAgent && monitor.type === "http") { - const dbSettings = await this.settingsService.getDBSettings(); - userAgent = dbSettings?.defaultUserAgent ?? undefined; - } - const headers: Record = {}; if (secret) headers["Authorization"] = `Bearer ${secret}`; - if (userAgent) headers["User-Agent"] = userAgent; + if (customUserAgent) headers["User-Agent"] = this.sanitizeHeaderValue(customUserAgent); const options: Record = { headers: Object.keys(headers).length > 0 ? headers : undefined, diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index f2daff5506..f103352554 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,7 +72,17 @@ 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) => { + this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null; return await this.getRepository().update(newSettings); }; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 521b5f72bf..bd01aaa3a7 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,7 +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: z.string().max(500).optional(), + customUserAgent: httpHeaderValueSchema.optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), @@ -105,7 +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: z.string().max(500).optional(), + customUserAgent: httpHeaderValueSchema.optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), @@ -159,7 +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: z.string().max(500).optional(), + customUserAgent: httpHeaderValueSchema.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 234037c8a8..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,7 +21,7 @@ export const updateAppSettingsBodyValidation = z systemEmailTLSServername: z.string().nullable().optional(), showURL: z.boolean().optional(), - defaultUserAgent: z.string().max(500).nullable().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(", ")}`, From ae85af26bf5210a6761725a651f4ef1f6a9bcbd8 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Wed, 1 Apr 2026 17:19:39 -0400 Subject: [PATCH 3/6] fix: linting error - incomplete url scheme check --- .claude/settings.local.json | 11 ----------- client/src/Pages/CreateMonitor/index.tsx | 2 +- client/src/Pages/Settings/index.tsx | 2 +- .../service/infrastructure/network/HttpProvider.ts | 2 +- 4 files changed, 3 insertions(+), 14 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 9ad155edd3..0000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh pr:*)", - "Bash(gh api:*)", - "WebFetch(domain:github.com)", - "Bash(xargs grep:*)", - "Bash(npx tsc:*)" - ] - } -} diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index a91f81b520..988187984e 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -817,7 +817,7 @@ const CreateMonitorPage = () => { field.onChange( e.target.value .replace(/[<>]/g, "") - .replace(/javascript:/gi, "") + .replace(/(?:javascript|data|vbscript):/gi, "") .replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only .slice(0, 500) ); diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 6b09f98e3e..8b0eb6b54f 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -446,7 +446,7 @@ export const SettingsPage = () => { field.onChange( e.target.value .replace(/[<>]/g, "") - .replace(/javascript:/gi, "") + .replace(/(?:javascript|data|vbscript):/gi, "") .replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only .slice(0, 500) ); diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index 89b2938dd5..37fc54254a 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -61,7 +61,7 @@ export class HttpProvider implements IStatusProvider { return value .replace(/[<>]/g, "") - .replace(/javascript:/gi, "") + .replace(/(?:javascript|data|vbscript):/gi, "") .replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // allow only RFC 7230 header-safe characters .trim() .slice(0, 500); From 987ed64256a24fdfe29258679e84ffb9725b14ea Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 13 Apr 2026 07:53:33 -0400 Subject: [PATCH 4/6] fix: timescaleDB migration fixes and changes --- client/src/Types/Monitor.ts | 2 +- client/src/Validation/monitor.ts | 2 ++ server/src/db/migration/timescaledb/index.ts | 2 ++ .../monitors/TimescaleMonitorsRepository.ts | 10 +++++++--- .../settings/TimescaleSettingsRepository.ts | 10 +++++++--- server/src/service/system/settingsService.ts | 9 +++++++-- server/src/types/monitor.ts | 2 +- server/src/validation/monitorValidation.ts | 6 +++--- 8 files changed, 30 insertions(+), 13 deletions(-) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 020909c0cb..13e0ef804f 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -73,7 +73,7 @@ export interface Monitor { gameId?: string; grpcServiceName?: string; group: string | null; - customUserAgent?: string; + customUserAgent?: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index c9ab10afc5..110360f126 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -45,6 +45,8 @@ const httpSchema = baseSchema.extend({ /^[\t\x20-\x7E\x80-\xFF]*$/, "Only printable characters, spaces, and tabs are allowed (RFC 7230 §3.2.6)" ) + .transform((val) => (val.trim() === "" ? null : val.trim())) + .nullable() .optional(), }); 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/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/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/system/settingsService.ts b/server/src/service/system/settingsService.ts index f103352554..194a1c2254 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -82,8 +82,13 @@ export class SettingsService implements ISettingsService { }; updateDbSettings = async (newSettings: SettingsUpdate) => { - this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null; - return await this.getRepository().update(newSettings); + const updated = await this.getRepository().update(newSettings); + // Only refresh the cache if defaultUserAgent was actually part of the update payload. + // `?? null` would clobber the cache on partial updates that don't include this field. + 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 cbe78aa4ab..8a551e1944 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -50,7 +50,7 @@ export interface Monitor { gameId?: string; grpcServiceName?: string; group: string | null; - customUserAgent?: string; + customUserAgent?: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index bd01aaa3a7..f83f5b591f 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -75,7 +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.optional(), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), @@ -105,7 +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.optional(), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), @@ -159,7 +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.optional(), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), From e145068287e3657af7af81ff53b9409f96310059 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 13 Apr 2026 08:34:02 -0400 Subject: [PATCH 5/6] fix: adding migration changes - TimescaleDB - userAgent --- .../timescaledb/0022_add_user_agent_fields.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 server/src/db/migration/timescaledb/0022_add_user_agent_fields.ts 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; + `); +}; From 206c6df4aacc8b13e6a17f85e126f299cb31479b Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 13 Apr 2026 12:48:50 -0400 Subject: [PATCH 6/6] fix: removing unnecessary changes --- server/src/service/system/settingsService.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index 194a1c2254..1c41ac28a3 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -83,8 +83,6 @@ export class SettingsService implements ISettingsService { updateDbSettings = async (newSettings: SettingsUpdate) => { const updated = await this.getRepository().update(newSettings); - // Only refresh the cache if defaultUserAgent was actually part of the update payload. - // `?? null` would clobber the cache on partial updates that don't include this field. if ("defaultUserAgent" in newSettings) { this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null; }