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
This commit is contained in:
Nico 2026-04-09 23:13:07 +02:00 committed by GitHub
parent 595a29056d
commit 70c7de1efc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 238 additions and 44 deletions

View file

@ -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) | | `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` | **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` | | `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` | | `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` | | `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |

View file

@ -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<string, string | undefined> = {}) => {
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<string, string | undefined>, 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.",
);
});
});

View file

@ -0,0 +1 @@
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb

View file

@ -0,0 +1 @@
too-short

View file

@ -3,6 +3,7 @@ import os from "node:os";
import { prettifyError, z } from "zod"; import { prettifyError, z } from "zod";
import "dotenv/config"; import "dotenv/config";
import { buildAllowedHosts } from "../lib/auth/base-url"; import { buildAllowedHosts } from "../lib/auth/base-url";
import { toMessage } from "@zerobyte/core/utils";
const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2"); const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2");
const getResticHostname = () => { const getResticHostname = () => {
@ -38,56 +39,20 @@ const envSchema = z
TRUSTED_ORIGINS: z.string().optional(), TRUSTED_ORIGINS: z.string().optional(),
TRUST_PROXY: z.string().default("false"), TRUST_PROXY: z.string().default("false"),
DISABLE_RATE_LIMITING: 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(), BASE_URL: z.string(),
ENABLE_DEV_PANEL: z.string().default("false"), ENABLE_DEV_PANEL: z.string().default("false"),
PROVISIONING_PATH: z.string().optional(), PROVISIONING_PATH: z.string().optional(),
}) })
.transform((s) => { .transform((s, ctx) => {
const baseUrl = unquote(s.BASE_URL); const baseUrl = unquote(s.BASE_URL);
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl]; const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl];
const authOrigins = [baseUrl, ...trustedOrigins]; const authOrigins = [baseUrl, ...trustedOrigins];
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins); const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
let appSecret = s.APP_SECRET;
for (const origin of invalidOrigins) { if (!appSecret && !s.APP_SECRET_FILE) {
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) {
const errorMessage = [ const errorMessage = [
"", "",
"================================================================================", "================================================================================",
@ -103,16 +68,88 @@ export const parseConfig = (env: unknown) => {
"IMPORTANT: Store this secret securely and back it up. If lost, encrypted data", "IMPORTANT: Store this secret securely and back it up. If lost, encrypted data",
"in the database will be unrecoverable.", "in the database will be unrecoverable.",
"================================================================================", "================================================================================",
"",
].join("\n"); ].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)}`); console.error(`Environment variable validation failed: ${prettifyError(result.error)}`);
process.exit(1); 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; return result.data;
}; };

View file

@ -37,7 +37,7 @@ cp secrets/aws_secret_access_key.example secrets/aws_secret_access_key
2. Edit `.env`: 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_AWS_ACCESS_KEY_ID`
- Set `ZEROBYTE_WEBDAV_PASSWORD` - Set `ZEROBYTE_WEBDAV_PASSWORD`
- Adjust `BASE_URL` and `TZ` if needed - Adjust `BASE_URL` and `TZ` if needed