fix(certificates): improve validation and prevent crash (#2910)

This commit is contained in:
Meier Lukas
2025-04-22 18:28:58 +02:00
committed by GitHub
parent 3172e6e0c4
commit c51424717d
5 changed files with 72 additions and 24 deletions

View File

@@ -1,7 +1,7 @@
import { X509Certificate } from "node:crypto";
import { notFound } from "next/navigation";
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
import { IconAlertTriangle, IconCertificate, IconCertificateOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import { auth } from "@homarr/auth/next";
@@ -31,11 +31,27 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
const t = await getI18n();
const certificates = await loadCustomRootCertificatesAsync();
const x509Certificates = certificates
.map((cert) => ({
...cert,
x509: new X509Certificate(cert.content),
}))
.sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
.map((cert) => {
try {
const x509 = new X509Certificate(cert.content);
return {
...cert,
isError: false,
x509,
} as const;
} catch {
return {
...cert,
isError: true,
x509: null,
} as const;
}
})
.sort((certA, certB) => {
if (certA.isError) return -1;
if (certB.isError) return 1;
return certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime();
});
return (
<>
@@ -57,32 +73,47 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
{x509Certificates.map((cert) => (
<Card key={cert.x509.fingerprint} withBorder>
<Card key={cert.fileName} withBorder>
<Group wrap="nowrap">
<IconCertificate
color={getMantineColor(iconColor(cert.x509.validToDate), 6)}
style={{ minWidth: 32 }}
size={32}
stroke={1.5}
/>
{cert.isError ? (
<IconAlertTriangle
color={getMantineColor("red", 6)}
style={{ minWidth: 32 }}
size={32}
stroke={1.5}
/>
) : (
<IconCertificate
color={getMantineColor(iconColor(cert.x509.validToDate), 6)}
style={{ minWidth: 32 }}
size={32}
stroke={1.5}
/>
)}
<Stack flex={1} gap="xs" maw="calc(100% - 48px)">
<Group justify="space-between" wrap="nowrap">
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{cert.x509.subject}
{cert.isError ? t("certificate.page.list.invalid.title") : cert.x509.subject}
</Text>
<Text c="gray.6" ta="end" size="sm">
{cert.fileName}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
{t("certificate.page.list.expires", {
when: new Intl.RelativeTimeFormat(locale).format(
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
"days",
),
})}
</Text>
{cert.isError ? (
<Text size="sm" c="gray.6">
{t("certificate.page.list.invalid.description")}
</Text>
) : (
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
{t("certificate.page.list.expires", {
when: new Intl.RelativeTimeFormat(locale).format(
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
"days",
),
})}
</Text>
)}
<RemoveCertificate fileName={cert.fileName} />
</Group>
</Stack>

View File

@@ -1,3 +1,5 @@
import { X509Certificate } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { zfd } from "zod-form-data";
@@ -16,6 +18,17 @@ export const certificateRouter = createTRPCRouter({
)
.mutation(async ({ input }) => {
const content = await input.file.text();
// Validate the certificate
try {
new X509Certificate(content);
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid certificate",
});
}
await addCustomRootCertificateAsync(input.file.name, content);
}),
removeCertificate: permissionRequiredProcedure

View File

@@ -29,7 +29,7 @@ export const loadCustomRootCertificatesAsync = async () => {
const dirContent = await fs.readdir(folder);
return await Promise.all(
dirContent
.filter((file) => file.endsWith(".crt"))
.filter((file) => file.endsWith(".crt") || file.endsWith(".pem"))
.map(async (file) => ({
content: await fs.readFile(path.join(folder, file), "utf8"),
fileName: file,

View File

@@ -3800,6 +3800,10 @@
"noResults": {
"title": "There are no certificates yet"
},
"invalid": {
"title": "Invalid certificate",
"description": "Failed to parse certificate"
},
"expires": "Expires {when}"
}
},

View File

@@ -24,7 +24,7 @@ export const superRefineCertificateFile = (value: File | null, context: z.Refine
});
}
if (value.type !== "application/x-x509-ca-cert" && value.type !== "application/pkix-cert") {
if (!value.name.endsWith(".crt") && !value.name.endsWith(".pem")) {
return context.addIssue({
code: "custom",
params: createCustomErrorParams({