From c12ee178c4cad0af2dbe858b020b3867a739c2dc Mon Sep 17 00:00:00 2001 From: grasfer Date: Sat, 18 Jul 2026 14:33:44 +0200 Subject: [PATCH 1/2] Add certificate/domain matching module Exact match preferred over one-label wildcard. Used by the certificate mismatch warning; extracted so the warning stands alone. Co-Authored-By: Claude Fable 5 --- frontend/src/modules/CertificateMatch.test.ts | 54 +++++++++++++++++++ frontend/src/modules/CertificateMatch.ts | 50 +++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 frontend/src/modules/CertificateMatch.test.ts create mode 100644 frontend/src/modules/CertificateMatch.ts diff --git a/frontend/src/modules/CertificateMatch.test.ts b/frontend/src/modules/CertificateMatch.test.ts new file mode 100644 index 0000000000..313a9479d9 --- /dev/null +++ b/frontend/src/modules/CertificateMatch.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { Certificate } from "src/api/backend"; +import { matchCertificate } from "./CertificateMatch"; + +const cert = (id: number, ...domainNames: string[]) => ({ id, domainNames }) as Certificate; + +describe("matchCertificate", () => { + const wildcard = cert(1, "*.grasfer.app"); + const exact = cert(2, "pbs.grasfer.app"); + const other = cert(3, "example.com", "www.example.com"); + + it("matches a wildcard cert one label deep", () => { + expect(matchCertificate([wildcard, other], ["whatever.grasfer.app"])?.id).toEqual(1); + }); + + it("prefers an exact match over a wildcard", () => { + expect(matchCertificate([wildcard, exact], ["pbs.grasfer.app"])?.id).toEqual(2); + expect(matchCertificate([exact, wildcard], ["pbs.grasfer.app"])?.id).toEqual(2); + }); + + it("does not match a wildcard two labels deep", () => { + expect(matchCertificate([wildcard], ["a.b.grasfer.app"])).toBeUndefined(); + }); + + it("does not match the bare wildcard base domain", () => { + expect(matchCertificate([wildcard], ["grasfer.app"])).toBeUndefined(); + }); + + it("requires the cert to cover every typed domain", () => { + // covering only one of two aliases would break TLS for the other + expect(matchCertificate([other], ["nope.org", "www.example.com"])).toBeUndefined(); + expect(matchCertificate([wildcard], ["a.grasfer.app", "b.example.net"])).toBeUndefined(); + }); + + it("matches when all domains are covered", () => { + expect(matchCertificate([other], ["example.com", "www.example.com"])?.id).toEqual(3); + expect(matchCertificate([wildcard], ["a.grasfer.app", "b.grasfer.app"])?.id).toEqual(1); + }); + + it("prefers a cert covering all domains exactly over a wildcard covering all", () => { + const both = cert(4, "a.grasfer.app", "b.grasfer.app"); + expect(matchCertificate([wildcard, both], ["a.grasfer.app", "b.grasfer.app"])?.id).toEqual(4); + }); + + it("is case insensitive", () => { + expect(matchCertificate([other], ["WWW.Example.COM"])?.id).toEqual(3); + }); + + it("returns undefined when nothing matches or input is empty", () => { + expect(matchCertificate([wildcard, exact, other], ["unrelated.net"])).toBeUndefined(); + expect(matchCertificate([], ["whatever.grasfer.app"])).toBeUndefined(); + expect(matchCertificate([wildcard], [])).toBeUndefined(); + }); +}); diff --git a/frontend/src/modules/CertificateMatch.ts b/frontend/src/modules/CertificateMatch.ts new file mode 100644 index 0000000000..85ebca9375 --- /dev/null +++ b/frontend/src/modules/CertificateMatch.ts @@ -0,0 +1,50 @@ +import type { Certificate } from "src/api/backend"; + +// "exact" beats "wildcard"; null = not covered +const covers = (certDomain: string, domain: string): "exact" | "wildcard" | null => { + if (certDomain === domain) { + return "exact"; + } + if (certDomain.startsWith("*.")) { + const suffix = certDomain.slice(1); // ".example.com" + const prefix = domain.slice(0, -suffix.length); + // nginx semantics: a wildcard covers exactly one extra label + if (domain.endsWith(suffix) && prefix && !prefix.includes(".")) { + return "wildcard"; + } + } + return null; +}; + +/** + * Finds a certificate covering ALL of the given domains (a host serves every + * domain with the one selected certificate). A cert covering every domain + * exactly wins; otherwise the first cert covering all of them. + */ +export function matchCertificate(certs: Certificate[], domains: string[]): Certificate | undefined { + const wanted = domains.map((d) => d.toLowerCase()); + if (!wanted.length) { + return undefined; + } + let partialExact: Certificate | undefined; + for (const cert of certs) { + const certDomains = (cert.domainNames || []).map((d) => d.toLowerCase()); + const kinds = wanted.map((domain) => { + let best: "exact" | "wildcard" | null = null; + for (const certDomain of certDomains) { + const kind = covers(certDomain, domain); + if (kind === "exact") return "exact"; + if (kind === "wildcard") best = "wildcard"; + } + return best; + }); + if (kinds.includes(null)) { + continue; // must cover every domain + } + if (!kinds.includes("wildcard")) { + return cert; // all-exact match wins outright + } + partialExact ||= cert; + } + return partialExact; +} From ab566d55f78538363495f300ad637ecd30157e51 Mon Sep 17 00:00:00 2001 From: grasfer Date: Fri, 17 Jul 2026 12:53:18 +0200 Subject: [PATCH 2/2] Warn when typed domains aren't covered by the selected certificate Non-blocking warning in the Proxy, Redirection and 404 host modals, shown under both the domain names field and the SSL certificate select (the two live on different tabs). Reuses the auto-pick coverage logic via a new uncoveredDomains() export; silent for None, "new" and while certificates load. Fixes #3. Co-Authored-By: Claude Fable 5 --- .../Form/CertificateMismatchWarning.tsx | 30 ++++++++++++++++++ frontend/src/components/Form/index.ts | 1 + frontend/src/locale/src/en.json | 3 ++ frontend/src/modals/DeadHostModal.tsx | 3 ++ frontend/src/modals/ProxyHostModal.tsx | 3 ++ frontend/src/modals/RedirectionHostModal.tsx | 31 ++++++++++++++----- frontend/src/modules/CertificateMatch.test.ts | 28 ++++++++++++++++- frontend/src/modules/CertificateMatch.ts | 12 +++++++ 8 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/Form/CertificateMismatchWarning.tsx diff --git a/frontend/src/components/Form/CertificateMismatchWarning.tsx b/frontend/src/components/Form/CertificateMismatchWarning.tsx new file mode 100644 index 0000000000..a3d69935a5 --- /dev/null +++ b/frontend/src/components/Form/CertificateMismatchWarning.tsx @@ -0,0 +1,30 @@ +import { IconAlertTriangle } from "@tabler/icons-react"; +import { useFormikContext } from "formik"; +import { useCertificates } from "src/hooks"; +import { T } from "src/locale"; +import { uncoveredDomains } from "src/modules/CertificateMatch"; + +/** + * Non-blocking warning shown when the selected certificate does not cover + * every typed domain name. Silent for "None" (0), "new" (no domains yet + * to compare against) and while the certificate list is loading. + */ +export function CertificateMismatchWarning() { + const { data: certificates } = useCertificates(); + const { values }: any = useFormikContext(); + const certificateId = values?.certificateId; + if (!certificateId || certificateId === "new") { + return null; + } + const cert = certificates?.find((c) => c.id === certificateId); + const uncovered = uncoveredDomains(cert, values?.domainNames || []); + if (!uncovered.length) { + return null; + } + return ( +

+ + +

+ ); +} diff --git a/frontend/src/components/Form/index.ts b/frontend/src/components/Form/index.ts index f218b2e330..0d072f1e3d 100644 --- a/frontend/src/components/Form/index.ts +++ b/frontend/src/components/Form/index.ts @@ -1,6 +1,7 @@ export * from "./AccessClientFields"; export * from "./AccessField"; export * from "./BasicAuthFields"; +export * from "./CertificateMismatchWarning"; export * from "./DNSProviderFields"; export * from "./DomainNamesField"; export * from "./LocationsFields"; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..1563b496b9 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -692,6 +692,9 @@ "ssl-certificate": { "defaultMessage": "SSL Certificate" }, + "ssl-certificate-mismatch": { + "defaultMessage": "Selected certificate does not cover: {domains}" + }, "stream": { "defaultMessage": "Stream" }, diff --git a/frontend/src/modals/DeadHostModal.tsx b/frontend/src/modals/DeadHostModal.tsx index c9dce9a1c4..14e107d0f5 100644 --- a/frontend/src/modals/DeadHostModal.tsx +++ b/frontend/src/modals/DeadHostModal.tsx @@ -6,6 +6,7 @@ import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { Button, + CertificateMismatchWarning, DomainNamesField, Loading, NginxConfigField, @@ -132,6 +133,7 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
+
{ label="ssl-certificate" allowNew /> +
diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51bb..7217aa81e8 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -8,6 +8,7 @@ import Modal from "react-bootstrap/Modal"; import { AccessField, Button, + CertificateMismatchWarning, DomainNamesField, HasPermission, Loading, @@ -164,6 +165,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
+
@@ -340,6 +342,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { label="ssl-certificate" allowNew /> +
diff --git a/frontend/src/modals/RedirectionHostModal.tsx b/frontend/src/modals/RedirectionHostModal.tsx index 1bd7877f4f..3c24cbf2db 100644 --- a/frontend/src/modals/RedirectionHostModal.tsx +++ b/frontend/src/modals/RedirectionHostModal.tsx @@ -7,6 +7,7 @@ import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { Button, + CertificateMismatchWarning, DomainNamesField, Loading, NginxConfigField, @@ -145,6 +146,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
+
@@ -162,7 +164,9 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = required {...field} > - + @@ -224,12 +228,24 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = required {...field} > - - - - - - + + + + + + {form.errors.forwardHttpCode ? (
@@ -302,6 +318,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = label="ssl-certificate" allowNew /> +
diff --git a/frontend/src/modules/CertificateMatch.test.ts b/frontend/src/modules/CertificateMatch.test.ts index 313a9479d9..0ab7e3c35f 100644 --- a/frontend/src/modules/CertificateMatch.test.ts +++ b/frontend/src/modules/CertificateMatch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Certificate } from "src/api/backend"; -import { matchCertificate } from "./CertificateMatch"; +import { matchCertificate, uncoveredDomains } from "./CertificateMatch"; const cert = (id: number, ...domainNames: string[]) => ({ id, domainNames }) as Certificate; @@ -52,3 +52,29 @@ describe("matchCertificate", () => { expect(matchCertificate([wildcard], [])).toBeUndefined(); }); }); + +describe("uncoveredDomains", () => { + const wildcard = cert(1, "*.grasfer.app"); + const other = cert(3, "example.com", "www.example.com"); + + it("returns nothing when every domain is covered", () => { + expect(uncoveredDomains(other, ["example.com", "www.example.com"])).toEqual([]); + expect(uncoveredDomains(wildcard, ["a.grasfer.app", "b.grasfer.app"])).toEqual([]); + }); + + it("returns only the uncovered domains, original casing preserved", () => { + expect(uncoveredDomains(other, ["Example.COM", "Nope.org"])).toEqual(["Nope.org"]); + }); + + it("applies one-label wildcard semantics", () => { + expect(uncoveredDomains(wildcard, ["a.grasfer.app", "a.b.grasfer.app", "grasfer.app"])).toEqual([ + "a.b.grasfer.app", + "grasfer.app", + ]); + }); + + it("returns nothing without a certificate or domains", () => { + expect(uncoveredDomains(undefined, ["example.com"])).toEqual([]); + expect(uncoveredDomains(other, [])).toEqual([]); + }); +}); diff --git a/frontend/src/modules/CertificateMatch.ts b/frontend/src/modules/CertificateMatch.ts index 85ebca9375..fb95c00800 100644 --- a/frontend/src/modules/CertificateMatch.ts +++ b/frontend/src/modules/CertificateMatch.ts @@ -16,6 +16,18 @@ const covers = (certDomain: string, domain: string): "exact" | "wildcard" | null return null; }; +/** + * The typed domains (original casing, for display) that the given certificate + * does not cover. No certificate means nothing to warn about: []. + */ +export function uncoveredDomains(cert: Certificate | undefined, domains: string[]): string[] { + if (!cert) { + return []; + } + const certDomains = (cert.domainNames || []).map((d) => d.toLowerCase()); + return domains.filter((domain) => !certDomains.some((certDomain) => covers(certDomain, domain.toLowerCase()))); +} + /** * Finds a certificate covering ALL of the given domains (a host serves every * domain with the one selected certificate). A cert covering every domain