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 new file mode 100644 index 0000000000..0ab7e3c35f --- /dev/null +++ b/frontend/src/modules/CertificateMatch.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { Certificate } from "src/api/backend"; +import { matchCertificate, uncoveredDomains } 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(); + }); +}); + +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 new file mode 100644 index 0000000000..fb95c00800 --- /dev/null +++ b/frontend/src/modules/CertificateMatch.ts @@ -0,0 +1,62 @@ +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; +}; + +/** + * 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 + * 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; +}