fix: add caching for defaultUA db call and code fixes
This commit is contained in:
1 parent
babe5ae177
commit
023cf5782d
13 files changed
+89
-19
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(gh pr:*)",
|
||||
"Bash(gh api:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(xargs grep:*)",
|
||||
"Bash(npx tsc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<HttpStatusPayload> {
|
||||
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<HttpStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
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<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
|
||||
const { url, secret, jsonPath, ignoreTlsErrors, customUserAgent } = monitor;
|
||||
|
||||
@@ -65,15 +74,9 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
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<string, string> = {};
|
||||
if (secret) headers["Authorization"] = `Bearer ${secret}`;
|
||||
if (userAgent) headers["User-Agent"] = userAgent;
|
||||
if (customUserAgent) headers["User-Agent"] = this.sanitizeHeaderValue(customUserAgent);
|
||||
|
||||
const options: Record<string, unknown> = {
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ISettingsService {
|
||||
loadSettings(): EnvConfig;
|
||||
getSettings(): EnvConfig;
|
||||
getDBSettings(): Promise<Settings>;
|
||||
getDefaultUserAgent(): Promise<string | undefined>;
|
||||
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
|
||||
}
|
||||
|
||||
@@ -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<string | undefined> => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<UserRole>()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), {
|
||||
message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`,
|
||||
|
||||
Reference in new issue
Block a user