fix(certificates): improve validation and prevent crash (#2910)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user