diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 5458a75d8..59e6902ee 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -54,6 +54,7 @@ export const getMonitorDefaults = ( expectedValue: data?.expectedValue || "", jsonPath: data?.jsonPath || "", customUpCodes: data?.customUpCodes || [], + headers: data?.headers || [], }; break; case "ping": @@ -151,6 +152,7 @@ export const getMonitorDefaults = ( expectedValue: "", jsonPath: "", customUpCodes: [], + headers: [], }; } diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 5c2b693e0..0f771c2d2 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -3,13 +3,15 @@ import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; -import { useForm, FormProvider } from "react-hook-form"; +import { useForm, useFieldArray, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; +import IconButton from "@mui/material/IconButton"; import { Trans, useTranslation } from "react-i18next"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; +import { Trash2 } from "lucide-react"; import { HeaderDeleteControls } from "@/Components/monitors"; import { GeoContinents } from "@/Types/GeoCheck"; @@ -286,6 +288,14 @@ const CreateMonitorPage = () => { defaultValues: defaults, }); const { watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; + const { + fields: headerFields, + append: appendHeader, + remove: removeHeader, + } = useFieldArray({ control: form.control, name: "headers" }); + // Cross-field errors (duplicate names) live on the array root, which + // getFieldState surfaces without reaching into the union-typed error object. + const headersError = form.getFieldState("headers", form.formState).error?.root?.message; useEffect(() => { reset(defaults); @@ -939,6 +949,64 @@ const CreateMonitorPage = () => { /> )} + {showStep(2) && watchedType === "http" && ( + + {headerFields.map((field, index) => ( + + + + removeHeader(index)} + aria-label={t( + "pages.createMonitor.form.headers.option.removeAriaLabel" + )} + > + + + + ))} + + {headersError && ( + + {headersError} + + )} + + } + /> + )} + {showStep(2) && supportsGeoCheck(watchedType) && ( httpStatusCodeSet.has(code), message: "Must be a valid HTTP status code", }); +// Request headers sent with every check. Names are RFC 9110 tokens and values +// are printable ASCII, so a malformed header is caught in the form instead of +// failing at request time and reporting the monitor as down. Connection-level +// headers are managed by the HTTP client and would break the request if +// overridden. Kept in sync with server/src/api/validation/monitorValidation.ts. +const headerNameRegex = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const headerValueRegex = /^[\x21-\x7e](?:[\x20-\x7e\t]*[\x21-\x7e])?$/; +const reservedHeaderNames = new Set([ + "host", + "content-length", + "transfer-encoding", + "connection", + "upgrade", + "keep-alive", +]); + +const headersSchema = z + .array( + z.object({ + key: z + .string() + .min(1, "Header name is required") + .regex( + headerNameRegex, + "Header name may only contain letters, digits and !#$%&'*+-.^_`|~" + ) + .refine((key) => !reservedHeaderNames.has(key.toLowerCase()), { + message: "This header is set automatically and cannot be overridden", + }), + value: z + .string() + .min(1, "Header value is required") + .regex( + headerValueRegex, + "Header value must be printable ASCII without leading or trailing whitespace" + ), + }) + ) + .refine( + (headers) => { + const keys = headers.map((h) => h.key.toLowerCase()); + return new Set(keys).size === keys.length; + }, + { message: "Duplicate header names are not allowed" } + ); + const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, @@ -88,6 +134,7 @@ const httpSchema = baseSchema.extend({ .array(httpStatusCode) .optional() .register(monitorStepRegistry, { step: 2 }), + headers: headersSchema.optional().register(monitorStepRegistry, { step: 2 }), ...geoCheckFields, }); diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 55f34650d..54c8f3543 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -603,6 +603,20 @@ "dns": "راقب ما إذا كان خادم DNS محدد يستجيب." } }, + "headers": { + "title": "ترويسات الطلب", + "description": "ترويسات مخصصة تُرسل مع كل طلب لهذا المراقب.", + "option": { + "name": { + "placeholder": "اسم الترويسة" + }, + "value": { + "placeholder": "قيمة الترويسة" + }, + "addButton": "إضافة ترويسة", + "removeAriaLabel": "إزالة الترويسة" + } + }, "ignoreTls": { "description": "تهيئة التحقق من شهادة TLS/SSL لاتصالات HTTPS.", "option": { diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 7e39eca86..6f65c399a 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -603,6 +603,20 @@ "dns": "Monitoritza si un servidor DNS específic respon." } }, + "headers": { + "title": "Capçaleres de la sol·licitud", + "description": "Capçaleres personalitzades que s'envien amb cada sol·licitud d'aquest monitor.", + "option": { + "name": { + "placeholder": "Nom de la capçalera" + }, + "value": { + "placeholder": "Valor de la capçalera" + }, + "addButton": "Afegeix una capçalera", + "removeAriaLabel": "Elimina la capçalera" + } + }, "ignoreTls": { "description": "Configura la validació del certificat TLS/SSL per a connexions HTTPS.", "option": { diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index d04b9b23b..737b1dedb 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -603,6 +603,20 @@ "dns": "Sledujte, zda konkrétní DNS server odpovídá." } }, + "headers": { + "title": "Hlavičky požadavku", + "description": "Vlastní hlavičky odesílané s každým požadavkem tohoto monitoru.", + "option": { + "name": { + "placeholder": "Název hlavičky" + }, + "value": { + "placeholder": "Hodnota hlavičky" + }, + "addButton": "Přidat hlavičku", + "removeAriaLabel": "Odebrat hlavičku" + } + }, "ignoreTls": { "description": "Nastavte ověřování TLS/SSL certifikátů pro HTTPS připojení.", "option": { diff --git a/client/src/locales/de.json b/client/src/locales/de.json index d43d5d52a..9d86dbc93 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -603,6 +603,20 @@ "dns": "Überwachen Sie, ob ein bestimmter DNS-Server antwortet." } }, + "headers": { + "title": "Anfrage-Header", + "description": "Benutzerdefinierte Header, die mit jeder Anfrage dieses Monitors gesendet werden.", + "option": { + "name": { + "placeholder": "Header-Name" + }, + "value": { + "placeholder": "Header-Wert" + }, + "addButton": "Header hinzufügen", + "removeAriaLabel": "Header entfernen" + } + }, "ignoreTls": { "description": "Konfigurieren Sie die TLS/SSL-Zertifikatsvalidierung für HTTPS-Verbindungen.", "option": { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e82f84949..4be0ca74a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -712,6 +712,20 @@ "dns": "Monitor if a specific DNS server is responding." } }, + "headers": { + "title": "Request headers", + "description": "Custom headers sent with every request for this monitor.", + "option": { + "name": { + "placeholder": "Header name" + }, + "value": { + "placeholder": "Header value" + }, + "addButton": "Add header", + "removeAriaLabel": "Remove header" + } + }, "ignoreTls": { "description": "Configure TLS/SSL certificate validation for HTTPS connections.", "option": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 589399a83..cfd604d49 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -603,6 +603,20 @@ "dns": "Monitorea si un servidor DNS específico responde." } }, + "headers": { + "title": "Cabeceras de la solicitud", + "description": "Cabeceras personalizadas que se envían con cada solicitud de este monitor.", + "option": { + "name": { + "placeholder": "Nombre de la cabecera" + }, + "value": { + "placeholder": "Valor de la cabecera" + }, + "addButton": "Añadir cabecera", + "removeAriaLabel": "Eliminar cabecera" + } + }, "ignoreTls": { "description": "Configure la validación de certificados TLS/SSL para conexiones HTTPS.", "option": { diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 030698d44..93fd0409f 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -603,6 +603,20 @@ "dns": "Tarkkaile, vastaako tietty DNS-palvelin." } }, + "headers": { + "title": "Pyynnön otsakkeet", + "description": "Mukautetut otsakkeet, jotka lähetetään tämän monitorin jokaisen pyynnön mukana.", + "option": { + "name": { + "placeholder": "Otsakkeen nimi" + }, + "value": { + "placeholder": "Otsakkeen arvo" + }, + "addButton": "Lisää otsake", + "removeAriaLabel": "Poista otsake" + } + }, "ignoreTls": { "description": "Määritä TLS/SSL-varmenteen vahvistus HTTPS-yhteyksiä varten.", "option": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 1e0a6b096..94e8a903e 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -603,6 +603,20 @@ "dns": "Surveillez si un serveur DNS spécifique répond." } }, + "headers": { + "title": "En-têtes de requête", + "description": "En-têtes personnalisés envoyés avec chaque requête de ce moniteur.", + "option": { + "name": { + "placeholder": "Nom de l'en-tête" + }, + "value": { + "placeholder": "Valeur de l'en-tête" + }, + "addButton": "Ajouter un en-tête", + "removeAriaLabel": "Supprimer l'en-tête" + } + }, "ignoreTls": { "description": "Configurez la validation des certificats TLS/SSL pour les connexions HTTPS.", "option": { diff --git a/client/src/locales/it.json b/client/src/locales/it.json index 3d6d1ea16..4030bc2bd 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -619,6 +619,20 @@ "dns": "Monitora se uno specifico server DNS sta rispondendo." } }, + "headers": { + "title": "Intestazioni della richiesta", + "description": "Intestazioni personalizzate inviate con ogni richiesta di questo monitor.", + "option": { + "name": { + "placeholder": "Nome dell'intestazione" + }, + "value": { + "placeholder": "Valore dell'intestazione" + }, + "addButton": "Aggiungi intestazione", + "removeAriaLabel": "Rimuovi intestazione" + } + }, "ignoreTls": { "description": "Configura la validazione del certificato TLS/SSL per le connessioni HTTPS.", "option": { diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 16e2ec641..b9c228d45 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -603,6 +603,20 @@ "dns": "特定のDNSサーバーが応答しているかを監視します。" } }, + "headers": { + "title": "リクエストヘッダー", + "description": "このモニターのすべてのリクエストとともに送信されるカスタムヘッダー。", + "option": { + "name": { + "placeholder": "ヘッダー名" + }, + "value": { + "placeholder": "ヘッダーの値" + }, + "addButton": "ヘッダーを追加", + "removeAriaLabel": "ヘッダーを削除" + } + }, "ignoreTls": { "description": "HTTPS接続のTLS/SSL証明書検証を設定します。", "option": { diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json index 81509ee6f..5c9f0ba2a 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -645,6 +645,20 @@ }, "title": "Kontrole rozproszone geograficznie" }, + "headers": { + "title": "Nagłówki żądania", + "description": "Niestandardowe nagłówki wysyłane z każdym żądaniem tego monitora.", + "option": { + "name": { + "placeholder": "Nazwa nagłówka" + }, + "value": { + "placeholder": "Wartość nagłówka" + }, + "addButton": "Dodaj nagłówek", + "removeAriaLabel": "Usuń nagłówek" + } + }, "ignoreTls": { "description": "Skonfiguruj weryfikację certyfikatu TLS/SSL dla połączeń HTTPS.", "option": { diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index c75ead3ef..c5f45da9c 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -603,6 +603,20 @@ "dns": "Monitore se um servidor DNS específico está respondendo." } }, + "headers": { + "title": "Cabeçalhos da requisição", + "description": "Cabeçalhos personalizados enviados em cada requisição deste monitor.", + "option": { + "name": { + "placeholder": "Nome do cabeçalho" + }, + "value": { + "placeholder": "Valor do cabeçalho" + }, + "addButton": "Adicionar cabeçalho", + "removeAriaLabel": "Remover cabeçalho" + } + }, "ignoreTls": { "description": "Configure a validação de certificados TLS/SSL para conexões HTTPS.", "option": { diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index cf5e486f7..67b476200 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -603,6 +603,20 @@ "dns": "Отслеживайте, отвечает ли конкретный DNS-сервер." } }, + "headers": { + "title": "Заголовки запроса", + "description": "Пользовательские заголовки, отправляемые с каждым запросом этого монитора.", + "option": { + "name": { + "placeholder": "Имя заголовка" + }, + "value": { + "placeholder": "Значение заголовка" + }, + "addButton": "Добавить заголовок", + "removeAriaLabel": "Удалить заголовок" + } + }, "ignoreTls": { "description": "Настройте проверку сертификатов TLS/SSL для HTTPS-подключений.", "option": { diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 66d354adc..e98c8b53f 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -603,6 +603,20 @@ "dns": "ติดตามว่าเซิร์ฟเวอร์ DNS เฉพาะตอบกลับหรือไม่" } }, + "headers": { + "title": "ส่วนหัวของคำขอ", + "description": "ส่วนหัวที่กำหนดเองซึ่งจะถูกส่งไปพร้อมกับทุกคำขอของมอนิเตอร์นี้", + "option": { + "name": { + "placeholder": "ชื่อส่วนหัว" + }, + "value": { + "placeholder": "ค่าของส่วนหัว" + }, + "addButton": "เพิ่มส่วนหัว", + "removeAriaLabel": "ลบส่วนหัว" + } + }, "ignoreTls": { "description": "กำหนดค่าการตรวจสอบใบรับรอง TLS/SSL สำหรับการเชื่อมต่อ HTTPS", "option": { diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 26f8aace0..c0456d737 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -603,6 +603,20 @@ "dns": "Belirli bir DNS sunucusunun yanıt verip vermediğini izleyin." } }, + "headers": { + "title": "İstek başlıkları", + "description": "Bu monitörün her isteğiyle birlikte gönderilen özel başlıklar.", + "option": { + "name": { + "placeholder": "Başlık adı" + }, + "value": { + "placeholder": "Başlık değeri" + }, + "addButton": "Başlık ekle", + "removeAriaLabel": "Başlığı kaldır" + } + }, "ignoreTls": { "description": "HTTPS bağlantıları için TLS/SSL sertifika doğrulamasını yapılandırın.", "option": { diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index d1cb983d1..8cc27f789 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -603,6 +603,20 @@ "dns": "Стежте, чи відповідає певний DNS-сервер." } }, + "headers": { + "title": "Заголовки запиту", + "description": "Користувацькі заголовки, що надсилаються з кожним запитом цього монітора.", + "option": { + "name": { + "placeholder": "Ім'я заголовка" + }, + "value": { + "placeholder": "Значення заголовка" + }, + "addButton": "Додати заголовок", + "removeAriaLabel": "Видалити заголовок" + } + }, "ignoreTls": { "description": "Налаштуйте перевірку сертифікатів TLS/SSL для HTTPS-з'єднань.", "option": { diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 81da1c6fc..7209bf168 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -603,6 +603,20 @@ "dns": "Giám sát xem một máy chủ DNS cụ thể có phản hồi hay không." } }, + "headers": { + "title": "Tiêu đề yêu cầu", + "description": "Tiêu đề tùy chỉnh được gửi kèm mọi yêu cầu của trình giám sát này.", + "option": { + "name": { + "placeholder": "Tên tiêu đề" + }, + "value": { + "placeholder": "Giá trị tiêu đề" + }, + "addButton": "Thêm tiêu đề", + "removeAriaLabel": "Xóa tiêu đề" + } + }, "ignoreTls": { "description": "Cấu hình xác thực chứng chỉ TLS/SSL cho kết nối HTTPS.", "option": { diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 1a455d41c..386128643 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -604,6 +604,20 @@ "dns": "监控特定DNS服务器是否响应。" } }, + "headers": { + "title": "请求标头", + "description": "随该监控的每次请求一起发送的自定义标头。", + "option": { + "name": { + "placeholder": "标头名称" + }, + "value": { + "placeholder": "标头值" + }, + "addButton": "添加标头", + "removeAriaLabel": "删除标头" + } + }, "ignoreTls": { "description": "配置 HTTPS 连接的 TLS/SSL 证书验证。", "option": { diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index a15a2a2bb..7226c2d5b 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -603,6 +603,20 @@ "dns": "監控特定DNS伺服器是否回應。" } }, + "headers": { + "title": "請求標頭", + "description": "隨此監控的每次請求一併傳送的自訂標頭。", + "option": { + "name": { + "placeholder": "標頭名稱" + }, + "value": { + "placeholder": "標頭值" + }, + "addButton": "新增標頭", + "removeAriaLabel": "移除標頭" + } + }, "ignoreTls": { "description": "設定 HTTPS 連線的 TLS/SSL 憑證驗證。", "option": { diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index e694a216d..4f7a731c2 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -16,6 +16,35 @@ import { DateRanges, SortOrders } from "@/types/query.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); +// Request headers for HTTP monitors. Names are RFC 9110 tokens and values are +// printable ASCII, so a malformed header is rejected here instead of failing at +// request time and reporting the monitor as down. Connection-level headers are +// managed by the HTTP client and would break the request if overridden. +// Kept in sync with the client-side schema in client/src/Validation/monitor.ts. +const headerNameRegex = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const headerValueRegex = /^[\x21-\x7e](?:[\x20-\x7e\t]*[\x21-\x7e])?$/; +const reservedHeaderNames = new Set(["host", "content-length", "transfer-encoding", "connection", "upgrade", "keep-alive"]); + +const monitorHeaderSchema = z.object({ + key: z + .string() + .min(1, "Header name is required") + .regex(headerNameRegex, "Header name may only contain letters, digits and !#$%&'*+-.^_`|~") + .refine((key) => !reservedHeaderNames.has(key.toLowerCase()), { message: "This header is set automatically and cannot be overridden" }), + value: z + .string() + .min(1, "Header value is required") + .regex(headerValueRegex, "Header value must be printable ASCII without leading or trailing whitespace"), +}); + +const headersArraySchema = z.array(monitorHeaderSchema).refine( + (headers) => { + const keys = headers.map((h) => h.key.toLowerCase()); + return new Set(keys).size === keys.length; + }, + { message: "Duplicate header names are not allowed" } +); + // The client form submits proxyId: "" when no proxy is selected, set it to undefined const proxyIdValidation = z .string() @@ -142,6 +171,7 @@ export const createMonitorBodyValidation = z tags: z.array(z.string()).optional(), customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), + headers: headersArraySchema.optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), @@ -176,6 +206,7 @@ export const editMonitorBodyValidation = z tags: z.array(z.string()).optional(), customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), + headers: headersArraySchema.optional(), ignoreTlsErrors: z.boolean().optional(), proxyMode: z.enum(ProxyModes).optional(), proxyId: proxyIdValidation, @@ -255,6 +286,7 @@ const importedMonitorSchema = z tags: z.array(z.string()).default([]), customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), + headers: headersArraySchema.default([]), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), memoryAlertThreshold: z.number().default(100), @@ -323,6 +355,7 @@ export const monitorResponseSchema = z tags: z.array(z.string()), customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), + headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), diskAlertThreshold: z.number(), diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 15b277efb..f3e0517d2 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -1,5 +1,5 @@ import { Schema, model, Types } from "mongoose"; -import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; +import type { Monitor, MonitorHeader, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods, ProxyModes } from "@/domain/monitors/monitor.type.js"; import type { CheckAudits, @@ -118,6 +118,14 @@ const checkSnapshotSchema = new Schema( { _id: false, suppressReservedKeysWarning: true } ); +const headerSchema = new Schema( + { + key: { type: String, required: true }, + value: { type: String, required: true }, + }, + { _id: false } +); + const MonitorSchema = new Schema( { userId: { @@ -233,6 +241,10 @@ const MonitorSchema = new Schema( secret: { type: String, }, + headers: { + type: [headerSchema], + default: [], + }, cpuAlertThreshold: { type: Number, default: 100, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 8f773f983..ebd86954c 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -436,6 +436,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { tags: tagIds, customUpCodes: doc.customUpCodes ?? [], secret: doc.secret ?? undefined, + // Subdocuments carry mongoose internals, so map to plain header pairs + headers: (doc.headers ?? []).map(({ key, value }) => ({ key, value })), cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, memoryAlertThreshold: doc.memoryAlertThreshold, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 59d91ced9..ba885ed8b 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -71,6 +71,11 @@ export type HttpMethod = (typeof HttpMethods)[number]; export const MAX_RECENT_CHECKS = 50; +export interface MonitorHeader { + key: string; + value: string; +} + export interface Monitor { id: string; userId: string; @@ -99,6 +104,7 @@ export interface Monitor { tags: string[]; customUpCodes: HttpStatusCode[]; secret?: string; + headers?: MonitorHeader[]; cpuAlertThreshold: number; cpuAlertCounter: number; memoryAlertThreshold: number; diff --git a/server/src/service/network/HttpProvider.ts b/server/src/service/network/HttpProvider.ts index 1ef35f474..b7caa454b 100644 --- a/server/src/service/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -209,8 +209,18 @@ export class HttpProvider implements IStatusProvider { throw new Error("URL is required for HTTP monitor"); } + // User-defined headers first; `secret` owns Authorization, so it is applied + // last and wins if a header of that name was also configured. + const headers: Record = {}; + for (const { key, value } of monitor.headers ?? []) { + headers[key] = value; + } + if (secret) { + headers["Authorization"] = `Bearer ${secret}`; + } + const options: Record = { - headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, + headers: Object.keys(headers).length > 0 ? headers : undefined, }; options.agent = ctx?.proxyUrl diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 4e559ab06..527f4c96d 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -435,6 +435,49 @@ describe("HttpProvider", () => { }); }); + // ── request headers ────────────────────────────────────────────────── + + describe("request headers", () => { + it("sends configured headers when there is no secret", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ headers: [{ key: "X-Api-Key", value: "abc" }] })); + + expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ headers: { "X-Api-Key": "abc" } })); + }); + + it("merges configured headers with the secret-derived Authorization header", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ headers: [{ key: "X-Custom", value: "val" }], secret: "tok" })); + + expect(mockGot).toHaveBeenCalledWith( + "https://example.com", + expect.objectContaining({ headers: { "X-Custom": "val", Authorization: "Bearer tok" } }) + ); + }); + + it("lets the secret win over a configured Authorization header", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ headers: [{ key: "Authorization", value: "Bearer user-token" }], secret: "secret-token" })); + + expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ headers: { Authorization: "Bearer secret-token" } })); + }); + + it("sends no headers when none are configured and there is no secret", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor()); + + expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ headers: undefined })); + }); + }); + // ── HTTP method (GET / HEAD) ───────────────────────────────────────── describe("request method", () => { diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index ea2257072..2d56c3ae4 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -565,3 +565,74 @@ describe("monitorValidation — proxy fields", () => { }); }); }); + +describe("monitorValidation — headers", () => { + const baseHttpBody = { + name: "HTTP check", + type: "http" as const, + url: "https://example.com", + }; + + describe("createMonitorBodyValidation", () => { + it("retains a valid headers array", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, headers: [{ key: "X-Api-Key", value: "abc" }] }); + expect(parsed.headers).toEqual([{ key: "X-Api-Key", value: "abc" }]); + }); + + it("treats headers as optional", () => { + expect(createMonitorBodyValidation.parse(baseHttpBody).headers).toBeUndefined(); + }); + + it("rejects an empty header name", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, headers: [{ key: "", value: "abc" }] })).toThrow("Header name is required"); + }); + + it("rejects an empty header value", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, headers: [{ key: "X-Api-Key", value: "" }] })).toThrow( + "Header value is required" + ); + }); + + it.each([["X Api Key"], ["X-Api-Key:"], ["X-Api-Key\n"], ["Ünicode"]])("rejects the invalid header name %j", (key) => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, headers: [{ key, value: "abc" }] })).toThrow(); + }); + + it.each([["abc\r\nX-Injected: 1"], [" abc"], ["abc "], ["café"]])("rejects the invalid header value %j", (value) => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, headers: [{ key: "X-Api-Key", value }] })).toThrow(); + }); + + it("rejects a header the HTTP client manages itself", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, headers: [{ key: "Content-Length", value: "10" }] })).toThrow( + "This header is set automatically and cannot be overridden" + ); + }); + + it("rejects duplicate header names regardless of case", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + headers: [ + { key: "X-Api-Key", value: "a" }, + { key: "x-api-key", value: "b" }, + ], + }) + ).toThrow("Duplicate header names are not allowed"); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("retains a valid headers array on edit", () => { + expect(editMonitorBodyValidation.parse({ headers: [{ key: "X-Custom", value: "val" }] }).headers).toEqual([{ key: "X-Custom", value: "val" }]); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("defaults headers to an empty array when omitted", () => { + expect(importMonitorsBodyValidation.parse({ monitors: [baseHttpBody] }).monitors[0].headers).toEqual([]); + }); + + it("rejects an imported monitor with an invalid header", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHttpBody, headers: [{ key: "Bad Header", value: "v" }] }] })).toThrow(); + }); + }); +});