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

View File

@@ -1,3 +1,5 @@
import { X509Certificate } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { zfd } from "zod-form-data"; import { zfd } from "zod-form-data";
@@ -16,6 +18,17 @@ export const certificateRouter = createTRPCRouter({
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const content = await input.file.text(); 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); await addCustomRootCertificateAsync(input.file.name, content);
}), }),
removeCertificate: permissionRequiredProcedure removeCertificate: permissionRequiredProcedure

View File

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

View File

@@ -3800,6 +3800,10 @@
"noResults": { "noResults": {
"title": "There are no certificates yet" "title": "There are no certificates yet"
}, },
"invalid": {
"title": "Invalid certificate",
"description": "Failed to parse certificate"
},
"expires": "Expires {when}" "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({ return context.addIssue({
code: "custom", code: "custom",
params: createCustomErrorParams({ params: createCustomErrorParams({