Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions frontend/src/components/Form/CertificateMismatchWarning.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-warning">
<IconAlertTriangle size={16} className="me-1" />
<T id="ssl-certificate-mismatch" data={{ domains: uncovered.join(", ") }} />
</p>
);
}
1 change: 1 addition & 0 deletions frontend/src/components/Form/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/locale/src/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,9 @@
"ssl-certificate": {
"defaultMessage": "SSL Certificate"
},
"ssl-certificate-mismatch": {
"defaultMessage": "Selected certificate does not cover: {domains}"
},
"stream": {
"defaultMessage": "Stream"
},
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/modals/DeadHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import {
Button,
CertificateMismatchWarning,
DomainNamesField,
Loading,
NginxConfigField,
Expand Down Expand Up @@ -132,13 +133,15 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
<CertificateMismatchWarning />
</div>
<div className="tab-pane" id="tab-ssl" role="tabpanel">
<SSLCertificateField
name="certificateId"
label="ssl-certificate"
allowNew
/>
<CertificateMismatchWarning />
<SSLOptionsFields color="bg-red" />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/modals/ProxyHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Modal from "react-bootstrap/Modal";
import {
AccessField,
Button,
CertificateMismatchWarning,
DomainNamesField,
HasPermission,
Loading,
Expand Down Expand Up @@ -164,6 +165,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
<CertificateMismatchWarning />
<div className="row">
<div className="col-md-3">
<Field name="forwardScheme">
Expand Down Expand Up @@ -340,6 +342,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
label="ssl-certificate"
allowNew
/>
<CertificateMismatchWarning />
<SSLOptionsFields color="bg-lime" forProxyHost={true} />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
Expand Down
31 changes: 24 additions & 7 deletions frontend/src/modals/RedirectionHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import {
Button,
CertificateMismatchWarning,
DomainNamesField,
Loading,
NginxConfigField,
Expand Down Expand Up @@ -145,6 +146,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
<CertificateMismatchWarning />
<div className="row">
<div className="col-md-4">
<Field name="forwardScheme">
Expand All @@ -162,7 +164,9 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required
{...field}
>
<option value="auto"><T id="auto" /></option>
<option value="auto">
<T id="auto" />
</option>
<option value="http">http</option>
<option value="https">https</option>
</select>
Expand Down Expand Up @@ -224,12 +228,24 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required
{...field}
>
<option value="300"><T id="redirection-hosts.http-code.300" /></option>
<option value="301"><T id="redirection-hosts.http-code.301" /></option>
<option value="302"><T id="redirection-hosts.http-code.302" /></option>
<option value="303"><T id="redirection-hosts.http-code.303" /></option>
<option value="307"><T id="redirection-hosts.http-code.307" /></option>
<option value="308"><T id="redirection-hosts.http-code.308" /></option>
<option value="300">
<T id="redirection-hosts.http-code.300" />
</option>
<option value="301">
<T id="redirection-hosts.http-code.301" />
</option>
<option value="302">
<T id="redirection-hosts.http-code.302" />
</option>
<option value="303">
<T id="redirection-hosts.http-code.303" />
</option>
<option value="307">
<T id="redirection-hosts.http-code.307" />
</option>
<option value="308">
<T id="redirection-hosts.http-code.308" />
</option>
</select>
{form.errors.forwardHttpCode ? (
<div className="invalid-feedback">
Expand Down Expand Up @@ -302,6 +318,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
label="ssl-certificate"
allowNew
/>
<CertificateMismatchWarning />
<SSLOptionsFields color="bg-yellow" />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/modules/CertificateMatch.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
62 changes: 62 additions & 0 deletions frontend/src/modules/CertificateMatch.ts
Original file line number Diff line number Diff line change
@@ -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;
}