From a17845aa1caacc7ce0aa8827664a63b70ad7a88e Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 5 Oct 2026 09:48:06 +0200 Subject: [PATCH] feat: add create-only root certificate API (#7033) * feat: add create-only root certificate API [skip ci] * fix: harden root certificate API handling --- apps/docs/docs/management/api/index.mdx | 14 + apps/docs/public/api/open-api-schema.json | 267 ++++++++++++++++++ packages/api/src/open-api.ts | 5 + .../router/certificates/certificate-router.ts | 67 ++++- .../certificates/files/index.ts | 2 +- .../certificates/files/root-certificate.ts | 170 +++++++++++ .../src/infrastructure/certificates/index.ts | 5 + packages/validation/src/certificates.ts | 14 + 8 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/infrastructure/certificates/files/root-certificate.ts diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index ce05b7ebd..c01e17bdf 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -54,6 +54,20 @@ on every layout, and caps width to the available columns. `board.updateItemLayout` (`PATCH /api/boards/{boardId}/items/{itemId}/layouts/{layoutId}`) moves or resizes one item in one layout without replacing the board. It cannot move the item outside its section or overlap another item/container. +## Root CA automation + +Administrators can inspect a named CA with `GET /api/certificates/{fileName}` (`certificates.getCertificate`) or +create it with `POST /api/certificates/root` (`certificates.ensureRootCertificate`), using JSON `{ fileName, certificate }`. +Names are limited to 128 characters, start with a letter or digit, contain only letters, digits, dots, underscores and +hyphens, and end in `.crt` or `.pem`. Supply one currently valid self-signed PEM root CA, at most 64 KiB, without a chain +or private key. + +Inspection returns public metadata or `null` when absent. Creation returns `{ created, certificate }`; identical PEM +bytes return `created: false`, while different existing bytes or unsafe file paths return `CONFLICT` without replacement. +`sha256` hashes the exact UTF-8 PEM bytes, including whitespace; `fingerprint256` identifies the DER certificate. +Dates are ISO 8601. Certificate contents are never returned. Expired stored roots remain inspectable. Trust applies to +subsequent integration requests without a restart; it does not bypass hostname verification. + ## Docker targets Docker actions require `{ endpointId, id }` targets from `docker.getContainers`; `endpointId` distinguishes hosts. diff --git a/apps/docs/public/api/open-api-schema.json b/apps/docs/public/api/open-api-schema.json index 30129299f..e0e67aebb 100644 --- a/apps/docs/public/api/open-api-schema.json +++ b/apps/docs/public/api/open-api-schema.json @@ -5545,6 +5545,273 @@ } } }, + "/api/certificates/{fileName}": { + "get": { + "operationId": "certificates-getCertificate", + "summary": "Inspect a named custom root CA certificate", + "description": "Return public metadata or null when absent. Requires admin permission. Does not return PEM contents.", + "tags": [ + "certificates" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "fileName", + "schema": { + "type": "string", + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$" + }, + "sha256": { + "type": "string" + }, + "fingerprint256": { + "type": "string" + }, + "ca": { + "type": "boolean", + "const": true + }, + "validFrom": { + "type": "string" + }, + "validTo": { + "type": "string" + } + }, + "required": [ + "fileName", + "sha256", + "fingerprint256", + "ca", + "validFrom", + "validTo" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/api/certificates/root": { + "post": { + "operationId": "certificates-ensureRootCertificate", + "summary": "Ensure a named custom root CA certificate without overwriting", + "description": "Accept one currently valid self-signed PEM root CA (64 KiB maximum). Identical bytes are idempotent; different existing bytes return CONFLICT. Requires admin permission.", + "tags": [ + "certificates" + ], + "security": [ + { + "apikey": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$" + }, + "certificate": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "fileName", + "certificate" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "created": { + "type": "boolean" + }, + "certificate": { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$" + }, + "sha256": { + "type": "string" + }, + "fingerprint256": { + "type": "string" + }, + "ca": { + "type": "boolean", + "const": true + }, + "validFrom": { + "type": "string" + }, + "validTo": { + "type": "string" + } + }, + "required": [ + "fileName", + "sha256", + "fingerprint256", + "ca", + "validFrom", + "validTo" + ], + "additionalProperties": false + } + }, + "required": [ + "created", + "certificate" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, "/api/integrations/request": { "post": { "operationId": "integration-request", diff --git a/packages/api/src/open-api.ts b/packages/api/src/open-api.ts index f398036b1..cecd5a3ea 100644 --- a/packages/api/src/open-api.ts +++ b/packages/api/src/open-api.ts @@ -4,6 +4,7 @@ import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key"; import { appRouter } from "./router/app"; import { boardRouter } from "./router/board"; +import { ensureRootCertificateProcedure, getCertificateProcedure } from "./router/certificates/certificate-router"; import { integrationRequestProcedure } from "./router/integration/integration-request"; import { integrationTestStoredConnectionProcedure } from "./router/integration/integration-test-stored-connection"; import { infoRouter } from "./router/info"; @@ -21,6 +22,10 @@ export const openApiRouter = createTRPCRouter({ inviteRouter, serverSettingsRouter, userRouter, + certificates: createTRPCRouter({ + getCertificate: getCertificateProcedure, + ensureRootCertificate: ensureRootCertificateProcedure, + }), integration: createTRPCRouter({ request: integrationRequestProcedure, testConnection: integrationTestStoredConnectionProcedure, diff --git a/packages/api/src/router/certificates/certificate-router.ts b/packages/api/src/router/certificates/certificate-router.ts index cab6633f0..a64489cb1 100644 --- a/packages/api/src/router/certificates/certificate-router.ts +++ b/packages/api/src/router/certificates/certificate-router.ts @@ -5,18 +5,83 @@ import { z } from "zod/v4"; import { addCustomRootCertificateAsync, + ensureCustomRootCertificateAsync, + getCustomRootCertificateAsync, removeCustomRootCertificateAsync, + RootCertificateError, } from "@homarr/core/infrastructure/certificates"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { and, eq } from "@homarr/db"; import { trustedCertificateHostnames } from "@homarr/db/schema"; -import { certificateValidFileNameSchema, checkCertificateFile } from "@homarr/validation/certificates"; +import { + certificateValidFileNameSchema, + checkCertificateFile, + rootCertificateFileNameSchema, + rootCertificateMetadataSchema, +} from "@homarr/validation/certificates"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; const logger = createLogger({ module: "certificateRouter" }); +const withCertificateErrorsAsync = async (operation: () => Promise) => { + try { + return await operation(); + } catch (error) { + if (error instanceof RootCertificateError) { + throw new TRPCError({ code: error.code, message: error.message }); + } + throw error; + } +}; + +export const getCertificateProcedure = permissionRequiredProcedure + .requiresPermission("admin") + .meta({ + openapi: { + method: "GET", + path: "/api/certificates/{fileName}", + tags: ["certificates"], + protect: true, + summary: "Inspect a named custom root CA certificate", + description: + "Return public metadata or null when absent. Requires admin permission. Does not return PEM contents.", + }, + }) + .input(z.object({ fileName: rootCertificateFileNameSchema })) + .output(rootCertificateMetadataSchema.nullable()) + .query(({ input }) => withCertificateErrorsAsync(() => getCustomRootCertificateAsync(input.fileName))); + +export const ensureRootCertificateProcedure = permissionRequiredProcedure + .requiresPermission("admin") + .meta({ + openapi: { + method: "POST", + path: "/api/certificates/root", + tags: ["certificates"], + protect: true, + summary: "Ensure a named custom root CA certificate without overwriting", + description: + "Accept one currently valid self-signed PEM root CA (64 KiB maximum). Identical bytes are idempotent; different existing bytes return CONFLICT. Requires admin permission.", + }, + }) + .input( + z.object({ + fileName: rootCertificateFileNameSchema, + certificate: z + .string() + .min(1) + .max(64 * 1024), + }), + ) + .output(z.object({ created: z.boolean(), certificate: rootCertificateMetadataSchema })) + .mutation(({ input }) => + withCertificateErrorsAsync(() => ensureCustomRootCertificateAsync(input.fileName, input.certificate)), + ); + export const certificateRouter = createTRPCRouter({ + getCertificate: getCertificateProcedure, + ensureRootCertificate: ensureRootCertificateProcedure, addCertificate: permissionRequiredProcedure .requiresPermission("admin") .input( diff --git a/packages/core/src/infrastructure/certificates/files/index.ts b/packages/core/src/infrastructure/certificates/files/index.ts index fcc058ad9..3b7e7ad3c 100644 --- a/packages/core/src/infrastructure/certificates/files/index.ts +++ b/packages/core/src/infrastructure/certificates/files/index.ts @@ -4,7 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { rootCertificates } from "node:tls"; -const getCertificateFolder = () => { +export const getCertificateFolder = () => { if (process.env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH; return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates"); }; diff --git a/packages/core/src/infrastructure/certificates/files/root-certificate.ts b/packages/core/src/infrastructure/certificates/files/root-certificate.ts new file mode 100644 index 000000000..138e42a96 --- /dev/null +++ b/packages/core/src/infrastructure/certificates/files/root-certificate.ts @@ -0,0 +1,170 @@ +import { createHash, randomUUID, X509Certificate } from "node:crypto"; +import { constants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { createLogger } from "../../logs"; +import { getCertificateFolder } from "./index"; + +const logger = createLogger({ module: "rootCertificate" }); +const maxCertificateBytes = 64 * 1024; +const fileNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(crt|pem)$/; +const pemPattern = /^\s*-----BEGIN CERTIFICATE-----\s+([A-Za-z0-9+/=][A-Za-z0-9+/=\s]*)-----END CERTIFICATE-----\s*$/; + +export class RootCertificateError extends Error { + constructor( + public readonly code: "BAD_REQUEST" | "CONFLICT", + message: string, + ) { + super(message); + } +} + +const validateFileName = (fileName: string) => { + if (fileName.length > 128 || !fileNamePattern.test(fileName)) { + throw new RootCertificateError("BAD_REQUEST", "Invalid certificate file name"); + } +}; + +const describeCertificate = (fileName: string, content: Buffer, requireCurrent: boolean) => { + if (content.length > maxCertificateBytes) { + throw new RootCertificateError("BAD_REQUEST", "Expected one PEM root CA certificate, at most 64 KiB"); + } + const pem = pemPattern.exec(content.toString("utf8")); + if (!pem?.[1]) { + throw new RootCertificateError("BAD_REQUEST", "Expected one PEM root CA certificate, at most 64 KiB"); + } + + let certificate: X509Certificate; + try { + certificate = new X509Certificate(content); + // Reject extra DER data as well as PEM chains, keys, leaf certificates and non-root CAs. + const der = Buffer.from(pem[1].replace(/\s/g, ""), "base64"); + if ( + !certificate.raw.equals(der) || + !certificate.ca || + !certificate.checkIssued(certificate) || + !certificate.verify(certificate.publicKey) + ) { + throw new Error("Expected a self-signed root CA certificate"); + } + } catch { + throw new RootCertificateError("BAD_REQUEST", "Expected a self-signed root CA certificate"); + } + + const validFrom = new Date(certificate.validFrom); + const validTo = new Date(certificate.validTo); + const now = Date.now(); + if (requireCurrent && (validFrom.getTime() > now || validTo.getTime() <= now)) { + throw new RootCertificateError("BAD_REQUEST", "Root CA certificate is not currently valid"); + } + + return { + fileName, + sha256: createHash("sha256").update(content).digest("hex"), + fingerprint256: certificate.fingerprint256, + ca: true as const, + validFrom: validFrom.toISOString(), + validTo: validTo.toISOString(), + }; +}; + +const hasCode = (error: unknown, code: string) => { + return error instanceof Error && "code" in error && error.code === code; +}; + +const readCertificateAsync = async (fullPath: string) => { + let file; + try { + // NONBLOCK prevents opening a FIFO from hanging before the regular-file check. + file = await fs.open(fullPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + } catch (error) { + if (hasCode(error, "ENOENT")) return null; + if (hasCode(error, "ELOOP") || hasCode(error, "ENXIO")) { + throw new RootCertificateError("CONFLICT", "Certificate path is not a regular file"); + } + throw error; + } + + try { + const stat = await file.stat(); + if (!stat.isFile() || stat.size > maxCertificateBytes) { + throw new RootCertificateError("CONFLICT", "Certificate path is not a bounded regular file"); + } + const content = Buffer.alloc(maxCertificateBytes + 1); + let size = 0; + while (size < content.length) { + const { bytesRead } = await file.read(content, size, content.length - size, size); + if (bytesRead === 0) break; + size += bytesRead; + } + if (size > maxCertificateBytes) { + throw new RootCertificateError("CONFLICT", "Certificate file exceeds 64 KiB"); + } + return content.subarray(0, size); + } finally { + await file.close(); + } +}; + +export const getCustomRootCertificateAsync = async (fileName: string) => { + validateFileName(fileName); + const folder = getCertificateFolder(); + if (!folder) return null; + const content = await readCertificateAsync(path.join(folder, fileName)); + if (!content) return null; + return describeCertificate(fileName, content, false); +}; + +export const ensureCustomRootCertificateAsync = async (fileName: string, pem: string) => { + validateFileName(fileName); + const content = Buffer.from(pem, "utf8"); + const certificate = describeCertificate(fileName, content, true); + const folder = getCertificateFolder(); + if (!folder) { + throw new Error("Set LOCAL_CERTIFICATE_PATH to an absolute path to manage local certificates"); + } + await fs.mkdir(folder, { recursive: true }); + const fullPath = path.join(folder, fileName); + const existing = await readCertificateAsync(fullPath); + if (existing) { + if (!existing.equals(content)) { + throw new RootCertificateError("CONFLICT", "A different certificate already exists at this file name"); + } + return { created: false, certificate }; + } + + // Publish a complete file atomically without replacing an existing directory entry. + const temporaryPath = path.join(folder, `.root-certificate-${randomUUID()}.tmp`); + const temporaryFile = await fs.open( + temporaryPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o644, + ); + try { + try { + await temporaryFile.writeFile(content); + await temporaryFile.sync(); + } finally { + await temporaryFile.close(); + } + try { + await fs.link(temporaryPath, fullPath); + } catch (error) { + if (!hasCode(error, "EEXIST")) throw error; + const concurrent = await readCertificateAsync(fullPath); + if (!concurrent?.equals(content)) { + throw new RootCertificateError("CONFLICT", "A different certificate already exists at this file name"); + } + return { created: false, certificate }; + } + return { created: true, certificate }; + } finally { + // Cleanup must not turn a published certificate into a failed operation. + try { + await fs.unlink(temporaryPath); + } catch (error) { + logger.warn("Failed to remove temporary root certificate file", { error }); + } + } +}; diff --git a/packages/core/src/infrastructure/certificates/index.ts b/packages/core/src/infrastructure/certificates/index.ts index fb4ecaa0b..025fa9ff5 100644 --- a/packages/core/src/infrastructure/certificates/index.ts +++ b/packages/core/src/infrastructure/certificates/index.ts @@ -1,4 +1,9 @@ export { getTrustedCertificateHostnamesAsync } from "./hostnames"; +export { + ensureCustomRootCertificateAsync, + getCustomRootCertificateAsync, + RootCertificateError, +} from "./files/root-certificate"; export { addCustomRootCertificateAsync, removeCustomRootCertificateAsync, diff --git a/packages/validation/src/certificates.ts b/packages/validation/src/certificates.ts index f01a13c55..104f2067c 100644 --- a/packages/validation/src/certificates.ts +++ b/packages/validation/src/certificates.ts @@ -4,6 +4,20 @@ import { createCustomErrorParams } from "./form/i18n"; export const certificateValidFileNameSchema = z.string().regex(/^[\w\-. ]+$/); +export const rootCertificateFileNameSchema = z + .string() + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(crt|pem)$/); + +export const rootCertificateMetadataSchema = z.object({ + fileName: rootCertificateFileNameSchema, + sha256: z.string(), + fingerprint256: z.string(), + ca: z.literal(true), + validFrom: z.string(), + validTo: z.string(), +}); + export const checkCertificateFile: z.core.CheckFn = (context) => { const result = certificateValidFileNameSchema.safeParse(context.value.name); if (!result.success) {