From 023cf5782d8d4833ace89cc7fe4ba891082640d2 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Wed, 1 Apr 2026 16:52:55 -0400 Subject: [PATCH] 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 000000000..9ad155edd --- /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 86aac8741..a91f81b52 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 0b2c34890..6b09f98e3 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 1c575818e..c9ab10afc 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 4615b07f9..e0cf1ef9d 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 6f0574dff..ac168d5cc 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 19c6b4ee7..8ed72862a 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 b6908127b..b13d10267 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 02e1f0e82..89b2938dd 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 f2daff550..f10335255 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 521b5f72b..bd01aaa3a 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 234037c8a..1f9ee3ead 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 28fd13c82..8e11fbc3e 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(", ")}`,