diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx index 22c80fedf..cac4915d5 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx @@ -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 {x509Certificates.map((cert) => ( - + - + {cert.isError ? ( + + ) : ( + + )} - {cert.x509.subject} + {cert.isError ? t("certificate.page.list.invalid.title") : cert.x509.subject} {cert.fileName} - - {t("certificate.page.list.expires", { - when: new Intl.RelativeTimeFormat(locale).format( - dayjs(cert.x509.validToDate).diff(dayjs(), "days"), - "days", - ), - })} - + {cert.isError ? ( + + {t("certificate.page.list.invalid.description")} + + ) : ( + + {t("certificate.page.list.expires", { + when: new Intl.RelativeTimeFormat(locale).format( + dayjs(cert.x509.validToDate).diff(dayjs(), "days"), + "days", + ), + })} + + )} diff --git a/packages/api/src/router/certificates/certificate-router.ts b/packages/api/src/router/certificates/certificate-router.ts index 96d638f70..941f5f13a 100644 --- a/packages/api/src/router/certificates/certificate-router.ts +++ b/packages/api/src/router/certificates/certificate-router.ts @@ -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 diff --git a/packages/certificates/src/server.ts b/packages/certificates/src/server.ts index ad332ebd2..cd8ea9551 100644 --- a/packages/certificates/src/server.ts +++ b/packages/certificates/src/server.ts @@ -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, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8f34e8b7c..122b818ae 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3800,6 +3800,10 @@ "noResults": { "title": "There are no certificates yet" }, + "invalid": { + "title": "Invalid certificate", + "description": "Failed to parse certificate" + }, "expires": "Expires {when}" } }, diff --git a/packages/validation/src/certificates.ts b/packages/validation/src/certificates.ts index 08a11ef11..283d03a69 100644 --- a/packages/validation/src/certificates.ts +++ b/packages/validation/src/certificates.ts @@ -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({