From 70c7de1efc11094f5e50a3a12eeb21f5ce0221a5 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:13:07 +0200 Subject: [PATCH] feat(config): add support for APP_SECRET as a file (#769) * feat(config): add support for APP_SECRET as a file * fix(tsc): ensure appSecret is set in type system * fix: pr feedback --- README.md | 1 + app/server/core/__tests__/config.test.ts | 154 ++++++++++++++++++ .../core/__tests__/fixtures/app-secret.txt | 1 + .../__tests__/fixtures/short-app-secret.txt | 1 + app/server/core/config.ts | 123 +++++++++----- examples/provisioned-resources/README.md | 2 +- 6 files changed, 238 insertions(+), 44 deletions(-) create mode 100644 app/server/core/__tests__/config.test.ts create mode 100644 app/server/core/__tests__/fixtures/app-secret.txt create mode 100644 app/server/core/__tests__/fixtures/short-app-secret.txt diff --git a/README.md b/README.md index c3bef635..af1a43cb 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ Zerobyte can be customized using environment variables. Below are the available | :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- | | `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) | | `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) | +| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) | | `PORT` | The port the web interface and API will listen on. | `4096` | | `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` | | `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` | diff --git a/app/server/core/__tests__/config.test.ts b/app/server/core/__tests__/config.test.ts new file mode 100644 index 00000000..64e1b68b --- /dev/null +++ b/app/server/core/__tests__/config.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { fileURLToPath } from "node:url"; +import { parseConfig } from "../config"; + +const validAppSecret = "a".repeat(32); +const fileAppSecret = "b".repeat(32); +const appSecretFixturePath = fileURLToPath(new URL("./fixtures/app-secret.txt", import.meta.url)); +const shortAppSecretFixturePath = fileURLToPath(new URL("./fixtures/short-app-secret.txt", import.meta.url)); + +const createEnv = (overrides: Record = {}) => { + const env = { + APP_SECRET: validAppSecret, + BASE_URL: "http://example.com", + RESTIC_HOSTNAME: "configured-restic-host", + ...overrides, + }; + + return Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined)); +}; + +const expectParseConfigToExit = (env: Record, message: string) => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((code: string | number | null | undefined) => { + throw new Error(`process.exit:${code}`); + }); + + expect(() => parseConfig(env)).toThrow("process.exit:1"); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining(message)); +}; + +describe("parseConfig", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("parses quoted origins and derives runtime flags", () => { + const config = parseConfig( + createEnv({ + NODE_ENV: "development", + BASE_URL: '"https://example.com"', + TRUSTED_ORIGINS: '"https://admin.example.com", "http://localhost:3000"', + TRUST_PROXY: "true", + DISABLE_RATE_LIMITING: "true", + ENABLE_DEV_PANEL: "true", + SERVER_IP: "0.0.0.0", + SERVER_IDLE_TIMEOUT: "120", + PORT: "8080", + APP_VERSION: "1.2.3", + MIGRATIONS_PATH: "/tmp/migrations", + PROVISIONING_PATH: "/tmp/provisioning", + }), + ); + + expect(config).toEqual({ + __prod__: false, + environment: "development", + serverIp: "0.0.0.0", + serverIdleTimeout: 120, + resticHostname: "configured-restic-host", + port: 8080, + migrationsPath: "/tmp/migrations", + appVersion: "1.2.3", + trustedOrigins: ["https://admin.example.com", "http://localhost:3000", "https://example.com"], + trustProxy: true, + disableRateLimiting: true, + appSecret: validAppSecret, + baseUrl: "https://example.com", + isSecure: true, + enableDevPanel: true, + provisioningPath: "/tmp/provisioning", + allowedHosts: ["example.com", "admin.example.com", "localhost:3000"], + }); + }); + + test("uses the configured RESTIC_HOSTNAME when present", () => { + const config = parseConfig( + createEnv({ + RESTIC_HOSTNAME: "manual-restic-host", + }), + ); + + expect(config.resticHostname).toBe("manual-restic-host"); + }); + + test("reads APP_SECRET from APP_SECRET_FILE", () => { + const config = parseConfig( + createEnv({ + APP_SECRET: undefined, + APP_SECRET_FILE: appSecretFixturePath, + }), + ); + + expect(config.appSecret).toBe(fileAppSecret); + }); + + test("reads APP_SECRET from APP_SECRET_FILE when APP_SECRET is empty", () => { + const config = parseConfig( + createEnv({ + APP_SECRET: "", + APP_SECRET_FILE: appSecretFixturePath, + }), + ); + + expect(config.appSecret).toBe(fileAppSecret); + }); + + test("exits when APP_SECRET is missing", () => { + expectParseConfigToExit( + createEnv({ + APP_SECRET: undefined, + }), + "APP_SECRET is not configured.", + ); + }); + + test("exits when both APP_SECRET and APP_SECRET_FILE are set", () => { + expectParseConfigToExit( + createEnv({ + APP_SECRET_FILE: "/run/secrets/app-secret", + }), + "Both APP_SECRET and APP_SECRET_FILE are set. Please set only one of these.", + ); + }); + + test("exits when APP_SECRET_FILE cannot be read", () => { + expectParseConfigToExit( + createEnv({ + APP_SECRET: undefined, + APP_SECRET_FILE: "/path/that/does/not/exist", + }), + "Failed to read APP_SECRET from file:", + ); + }); + + test("exits when APP_SECRET_FILE contains a secret with an invalid length", () => { + expectParseConfigToExit( + createEnv({ + APP_SECRET: undefined, + APP_SECRET_FILE: shortAppSecretFixturePath, + }), + "The secret read from APP_SECRET_FILE must be between 32 and 256 characters long.", + ); + }); + + test("exits when no valid trusted origins are provided", () => { + expectParseConfigToExit( + createEnv({ + BASE_URL: "notaurl", + }), + "No valid trusted origins provided. Please check the BASE_URL and TRUSTED_ORIGINS environment variables.", + ); + }); +}); diff --git a/app/server/core/__tests__/fixtures/app-secret.txt b/app/server/core/__tests__/fixtures/app-secret.txt new file mode 100644 index 00000000..fba7d681 --- /dev/null +++ b/app/server/core/__tests__/fixtures/app-secret.txt @@ -0,0 +1 @@ +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb diff --git a/app/server/core/__tests__/fixtures/short-app-secret.txt b/app/server/core/__tests__/fixtures/short-app-secret.txt new file mode 100644 index 00000000..7cd213b4 --- /dev/null +++ b/app/server/core/__tests__/fixtures/short-app-secret.txt @@ -0,0 +1 @@ +too-short diff --git a/app/server/core/config.ts b/app/server/core/config.ts index 157b5edc..ef08b087 100644 --- a/app/server/core/config.ts +++ b/app/server/core/config.ts @@ -3,6 +3,7 @@ import os from "node:os"; import { prettifyError, z } from "zod"; import "dotenv/config"; import { buildAllowedHosts } from "../lib/auth/base-url"; +import { toMessage } from "@zerobyte/core/utils"; const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2"); const getResticHostname = () => { @@ -38,56 +39,20 @@ const envSchema = z TRUSTED_ORIGINS: z.string().optional(), TRUST_PROXY: z.string().default("false"), DISABLE_RATE_LIMITING: z.string().default("false"), - APP_SECRET: z.string().min(32).max(256), + APP_SECRET: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(32).max(256).optional()), + APP_SECRET_FILE: z.string().optional(), BASE_URL: z.string(), ENABLE_DEV_PANEL: z.string().default("false"), PROVISIONING_PATH: z.string().optional(), }) - .transform((s) => { + .transform((s, ctx) => { const baseUrl = unquote(s.BASE_URL); const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl]; const authOrigins = [baseUrl, ...trustedOrigins]; const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins); + let appSecret = s.APP_SECRET; - for (const origin of invalidOrigins) { - console.warn( - `Ignoring invalid origin in configuration: ${origin}. Make sure it is a valid URL with a protocol (e.g. https://example.com)`, - ); - } - - return { - __prod__: s.NODE_ENV === "production", - environment: s.NODE_ENV, - serverIp: s.SERVER_IP, - serverIdleTimeout: s.SERVER_IDLE_TIMEOUT, - resticHostname: s.RESTIC_HOSTNAME || getResticHostname(), - port: s.PORT, - migrationsPath: s.MIGRATIONS_PATH, - appVersion: s.APP_VERSION, - trustedOrigins: trustedOrigins, - trustProxy: s.TRUST_PROXY === "true", - disableRateLimiting: s.DISABLE_RATE_LIMITING === "true" || s.NODE_ENV === "test", - appSecret: s.APP_SECRET, - baseUrl, - isSecure: baseUrl.startsWith("https://"), - enableDevPanel: s.ENABLE_DEV_PANEL === "true", - provisioningPath: s.PROVISIONING_PATH, - allowedHosts, - }; - }); - -export const parseConfig = (env: unknown) => { - const result = envSchema.safeParse(env); - - if (result.success && result.data.allowedHosts.length === 0) { - console.error( - `Configuration error: No valid trusted origins provided. Please check the BASE_URL and TRUSTED_ORIGINS environment variables.`, - ); - process.exit(1); - } - - if (!result.success) { - if (!process.env.APP_SECRET) { + if (!appSecret && !s.APP_SECRET_FILE) { const errorMessage = [ "", "================================================================================", @@ -103,16 +68,88 @@ export const parseConfig = (env: unknown) => { "IMPORTANT: Store this secret securely and back it up. If lost, encrypted data", "in the database will be unrecoverable.", "================================================================================", - "", ].join("\n"); - console.error(errorMessage); + ctx.addIssue({ + code: "custom", + message: errorMessage, + }); } + if (s.APP_SECRET && s.APP_SECRET_FILE) { + ctx.addIssue({ + code: "custom", + message: "Both APP_SECRET and APP_SECRET_FILE are set. Please set only one of these.", + }); + } + + if (s.APP_SECRET_FILE) { + try { + appSecret = readFileSync(s.APP_SECRET_FILE, "utf-8").trim(); + if (appSecret.length < 32 || appSecret.length > 256) { + ctx.addIssue({ + code: "custom", + message: "The secret read from APP_SECRET_FILE must be between 32 and 256 characters long.", + }); + } + } catch (err) { + ctx.addIssue({ + code: "custom", + message: `Failed to read APP_SECRET from file: ${toMessage(err)}`, + }); + } + } + + for (const origin of invalidOrigins) { + console.warn( + `Ignoring invalid origin in configuration: ${origin}. Make sure it is a valid URL with a protocol (e.g. https://example.com)`, + ); + } + + if (allowedHosts.length === 0) { + ctx.addIssue({ + code: "custom", + message: + "No valid trusted origins provided. Please check the BASE_URL and TRUSTED_ORIGINS environment variables.", + }); + } + + return { + __prod__: s.NODE_ENV === "production", + environment: s.NODE_ENV, + serverIp: s.SERVER_IP, + serverIdleTimeout: s.SERVER_IDLE_TIMEOUT, + resticHostname: s.RESTIC_HOSTNAME || getResticHostname(), + port: s.PORT, + migrationsPath: s.MIGRATIONS_PATH, + appVersion: s.APP_VERSION, + trustedOrigins: trustedOrigins, + trustProxy: s.TRUST_PROXY === "true", + disableRateLimiting: s.DISABLE_RATE_LIMITING === "true" || s.NODE_ENV === "test", + appSecret: appSecret ?? "", + baseUrl, + isSecure: baseUrl.startsWith("https://"), + enableDevPanel: s.ENABLE_DEV_PANEL === "true", + provisioningPath: s.PROVISIONING_PATH, + allowedHosts, + }; + }); + +export const parseConfig = (env: unknown) => { + const result = envSchema.safeParse(env); + + if (!result.success) { console.error(`Environment variable validation failed: ${prettifyError(result.error)}`); process.exit(1); } + if (!result.data.appSecret) { + console.error( + "APP_SECRET is required but was not provided. Please set the APP_SECRET environment variable or provide a file with APP_SECRET_FILE.", + ); + process.exit(1); + } + return result.data; }; diff --git a/examples/provisioned-resources/README.md b/examples/provisioned-resources/README.md index 5d264355..5713fe0b 100644 --- a/examples/provisioned-resources/README.md +++ b/examples/provisioned-resources/README.md @@ -37,7 +37,7 @@ cp secrets/aws_secret_access_key.example secrets/aws_secret_access_key 2. Edit `.env`: -- Set `APP_SECRET` to a real secret, for example `openssl rand -hex 32` +- Set `APP_SECRET` to a real secret, for example `openssl rand -hex 32`, or set `APP_SECRET_FILE` to a mounted secret file - Set `ZEROBYTE_AWS_ACCESS_KEY_ID` - Set `ZEROBYTE_WEBDAV_PASSWORD` - Adjust `BASE_URL` and `TZ` if needed