diff --git a/.env.self-hosting.example b/.env.self-hosting.example new file mode 100644 index 00000000000..cd24c6bece9 --- /dev/null +++ b/.env.self-hosting.example @@ -0,0 +1,20 @@ +# Copy to .env next to docker-compose.yml and fill in every value. +# Generate each secret with: openssl rand -hex 32 +# cap-web and the media server refuse to start when these are missing or left at a known placeholder. +NEXTAUTH_SECRET= +DATABASE_ENCRYPTION_KEY= +MEDIA_SERVER_WEBHOOK_SECRET= + +# Optional: database and storage passwords (internal-only defaults are used when unset) +MYSQL_PASSWORD= +MYSQL_ROOT_PASSWORD= +MINIO_ROOT_USER=cap-admin +MINIO_ROOT_PASSWORD= + +# Public URLs +CAP_URL=http://localhost:3000 +S3_PUBLIC_URL=http://localhost:9000 + +# Optional: email. Without both values, login codes are printed to the cap-web logs. +RESEND_API_KEY= +RESEND_FROM_DOMAIN= diff --git a/.github/workflows/test-self-hosting.yml b/.github/workflows/test-self-hosting.yml index 3f5255fda97..4d5800c1fa1 100644 --- a/.github/workflows/test-self-hosting.yml +++ b/.github/workflows/test-self-hosting.yml @@ -7,14 +7,18 @@ on: - main paths: - "docker-compose.yml" + - "docker-compose.template.yml" - "docker-compose.coolify.yml" + - ".env.self-hosting.example" - "apps/media-server/**" - "apps/web/Dockerfile" - ".github/workflows/test-self-hosting.yml" pull_request: paths: - "docker-compose.yml" + - "docker-compose.template.yml" - "docker-compose.coolify.yml" + - ".env.self-hosting.example" - "apps/media-server/**" - "apps/web/Dockerfile" - ".github/workflows/test-self-hosting.yml" @@ -29,6 +33,32 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 + - name: Configure self-hosting secrets + run: | + cp .env.self-hosting.example .env + for name in NEXTAUTH_SECRET DATABASE_ENCRYPTION_KEY MEDIA_SERVER_WEBHOOK_SECRET; do + secret=$(openssl rand -hex 32) + echo "::add-mask::$secret" + sed -i.bak "s/^$name=.*/$name=$secret/" .env + done + rm .env.bak + + - name: Validate required Compose secrets + run: | + for file in docker-compose.yml docker-compose.template.yml; do + docker compose -f "$file" config --quiet + for name in NEXTAUTH_SECRET DATABASE_ENCRYPTION_KEY MEDIA_SERVER_WEBHOOK_SECRET; do + if env "$name=" docker compose -f "$file" config --quiet > "$RUNNER_TEMP/cap-compose-validation.log" 2>&1; then + echo "Expected $file to reject an empty $name" + exit 1 + fi + if ! grep -q "$name" "$RUNNER_TEMP/cap-compose-validation.log"; then + echo "$file did not identify the missing $name" + exit 1 + fi + done + done + - name: Start Cap with Docker Compose run: | echo "Starting Cap..." diff --git a/.gitignore b/.gitignore index 66b2c0900a3..841a5e1a5ed 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist dist-ssr *.local .env +.env.bak .env.production .env.development .env.test diff --git a/README.md b/README.md index 389a250fbf6..d2e41e4f6a2 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,11 @@ The fastest way to self-host Cap Web is Docker Compose: ```bash git clone https://github.com/CapSoftware/Cap.git cd Cap +cp .env.self-hosting.example .env +for name in NEXTAUTH_SECRET DATABASE_ENCRYPTION_KEY MEDIA_SERVER_WEBHOOK_SECRET; do + sed -i.bak "s/^$name=.*/$name=$(openssl rand -hex 32)/" .env +done +rm .env.bak docker compose up -d ``` diff --git a/apps/media-server/src/__tests__/lib/startup-checks.test.ts b/apps/media-server/src/__tests__/lib/startup-checks.test.ts new file mode 100644 index 00000000000..209f10adfc5 --- /dev/null +++ b/apps/media-server/src/__tests__/lib/startup-checks.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { getWebhookSecretProblem } from "../../lib/startup-checks"; + +describe("getWebhookSecretProblem", () => { + test("rejects a missing secret", () => { + expect(getWebhookSecretProblem({})).toContain("not set"); + }); + + test("rejects the old docker-compose placeholder", () => { + expect( + getWebhookSecretProblem({ + MEDIA_SERVER_WEBHOOK_SECRET: + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + }), + ).toContain("placeholder"); + }); + + test("allows a placeholder only with the explicit local override", () => { + expect( + getWebhookSecretProblem({ + MEDIA_SERVER_WEBHOOK_SECRET: "local-media-server-secret", + MEDIA_SERVER_ALLOW_INSECURE_SECRET: "true", + }), + ).toBeNull(); + }); + + test("accepts a unique secret", () => { + expect( + getWebhookSecretProblem({ + MEDIA_SERVER_WEBHOOK_SECRET: "4b1f0c9e7a2d43e8b6c5f1a09d8e7c6b", + }), + ).toBeNull(); + }); +}); diff --git a/apps/media-server/src/index.ts b/apps/media-server/src/index.ts index 05dbdac4fd3..bae43fd6124 100644 --- a/apps/media-server/src/index.ts +++ b/apps/media-server/src/index.ts @@ -6,8 +6,11 @@ import { cleanupMediaTransferCache, sweepMediaTransferCache, } from "./lib/media-transfer"; +import { assertSecureStartup } from "./lib/startup-checks"; import { removeOwnTempDir, startTempDirMaintenance } from "./lib/temp-files"; +assertSecureStartup(); + const port = Number(process.env.PORT) || 3456; console.log(`[media-server] Starting on port ${port}`); diff --git a/apps/media-server/src/lib/startup-checks.ts b/apps/media-server/src/lib/startup-checks.ts new file mode 100644 index 00000000000..718b705f759 --- /dev/null +++ b/apps/media-server/src/lib/startup-checks.ts @@ -0,0 +1,39 @@ +const KNOWN_PLACEHOLDER_SECRETS = new Set([ + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "c7a1e98e1e5e4cec5a3fbf5eaf502d262fb99807ae2be8ee70537409e29cb6f9", + "fe337b52749070bb7b5d2c78cff9948439ea73cbc1869ba39d350e6c24db53b1", + "local-media-server-secret", + "changeme", + "change-me", + "secret", +]); + +export function getWebhookSecretProblem( + env: Record = process.env, +): string | null { + const value = env.MEDIA_SERVER_WEBHOOK_SECRET?.trim(); + if (!value) return "MEDIA_SERVER_WEBHOOK_SECRET is not set"; + if ( + KNOWN_PLACEHOLDER_SECRETS.has(value.toLowerCase()) && + env.MEDIA_SERVER_ALLOW_INSECURE_SECRET !== "true" + ) { + return "MEDIA_SERVER_WEBHOOK_SECRET is set to a publicly known placeholder value"; + } + return null; +} + +export function assertSecureStartup( + env: Record = process.env, +) { + const problem = getWebhookSecretProblem(env); + if (!problem) return; + console.error( + [ + `[media-server] Refusing to start: ${problem}.`, + "Generate one with `openssl rand -hex 32` and set the same value on cap-web and the media server.", + ].join("\n"), + ); + process.exit(1); +} diff --git a/apps/web/__tests__/unit/insecure-secrets.test.ts b/apps/web/__tests__/unit/insecure-secrets.test.ts new file mode 100644 index 00000000000..dd04d14d0ae --- /dev/null +++ b/apps/web/__tests__/unit/insecure-secrets.test.ts @@ -0,0 +1,40 @@ +import { findInsecureSecrets, formatInsecureSecrets } from "@cap/env"; +import { describe, expect, it } from "vitest"; + +const strong = + "9f1c2b7e4d5a6f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708"; + +describe("findInsecureSecrets", () => { + it("flags missing and placeholder secrets", () => { + const problems = findInsecureSecrets({ + NEXTAUTH_SECRET: + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + DATABASE_ENCRYPTION_KEY: "", + MEDIA_SERVER_WEBHOOK_SECRET: strong, + }); + expect(problems).toEqual([ + { name: "NEXTAUTH_SECRET", problem: "placeholder" }, + { name: "DATABASE_ENCRYPTION_KEY", problem: "missing" }, + ]); + expect(formatInsecureSecrets(problems)).toContain("openssl rand -hex 32"); + }); + + it("accepts unique secrets", () => { + expect( + findInsecureSecrets({ + NEXTAUTH_SECRET: strong, + DATABASE_ENCRYPTION_KEY: `${strong.slice(1)}0`, + MEDIA_SERVER_WEBHOOK_SECRET: `${strong.slice(2)}00`, + }), + ).toEqual([]); + }); + + it("only checks the requested names", () => { + expect( + findInsecureSecrets( + { NEXTAUTH_SECRET: strong, DATABASE_ENCRYPTION_KEY: strong }, + ["NEXTAUTH_SECRET", "DATABASE_ENCRYPTION_KEY"], + ), + ).toEqual([]); + }); +}); diff --git a/apps/web/__tests__/unit/self-host-startup.test.ts b/apps/web/__tests__/unit/self-host-startup.test.ts new file mode 100644 index 00000000000..4f686c44f6c --- /dev/null +++ b/apps/web/__tests__/unit/self-host-startup.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const placeholder = + "c7a1e98e1e5e4cec5a3fbf5eaf502d262fb99807ae2be8ee70537409e29cb6f9"; + +async function register() { + vi.resetModules(); + const instrumentation = await import("../../instrumentation.node"); + await instrumentation.register(); +} + +describe("self-host startup secrets", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("NEXT_PUBLIC_IS_CAP", ""); + vi.stubEnv("DATABASE_URL", "mysql://localhost/cap"); + vi.stubEnv("WEB_URL", "http://localhost:3000"); + vi.stubEnv("NEXTAUTH_URL", "http://localhost:3000"); + vi.stubEnv("NEXTAUTH_SECRET", "ab".repeat(32)); + vi.stubEnv("DATABASE_ENCRYPTION_KEY", placeholder); + vi.stubEnv("CAP_AWS_BUCKET", "cap"); + vi.stubEnv("CAP_AWS_REGION", "us-east-1"); + vi.stubEnv("MEDIA_SERVER_URL", "http://media-server:3456"); + vi.stubEnv("MEDIA_SERVER_WEBHOOK_SECRET", "cd".repeat(32)); + vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "false"); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exit"); + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); + }); + + it("refuses a placeholder by default", async () => { + await expect(register()).rejects.toThrow("exit"); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it("keeps the old encryption key with an explicit override and warns on every start", async () => { + vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "true"); + await register(); + await register(); + expect(process.exit).not.toHaveBeenCalled(); + expect(process.env.DATABASE_ENCRYPTION_KEY).toBe(placeholder); + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("DATABASE_ENCRYPTION_KEY"), + ); + expect(console.warn).not.toHaveBeenCalledWith( + expect.stringContaining(placeholder), + ); + }); + + it("can decrypt existing data after upgrading with the old encryption key", async () => { + vi.resetModules(); + const { encrypt } = await import("@cap/database/crypto"); + const storedCredentials = await encrypt("existing-storage-credentials"); + vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "true"); + + await register(); + + const { decrypt } = await import("@cap/database/crypto"); + expect(await decrypt(storedCredentials)).toBe( + "existing-storage-credentials", + ); + }); + + it.each(["NEXTAUTH_SECRET", "MEDIA_SERVER_WEBHOOK_SECRET"])( + "does not bypass a missing %s with the upgrade override", + async (name) => { + vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "true"); + vi.stubEnv(name, ""); + await expect(register()).rejects.toThrow("exit"); + expect(process.exit).toHaveBeenCalledWith(1); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it.each(["", "invalid"])( + "does not bypass missing or invalid encryption keys (%j)", + async (key) => { + vi.stubEnv("CAP_ALLOW_INSECURE_SECRETS", "true"); + vi.stubEnv("DATABASE_ENCRYPTION_KEY", key); + await expect(register()).rejects.toThrow("exit"); + expect(process.exit).toHaveBeenCalledWith(1); + }, + ); + + it("starts normally with unique secrets", async () => { + vi.stubEnv("DATABASE_ENCRYPTION_KEY", "ef".repeat(32)); + await register(); + expect(process.exit).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/content/changelog/web/2026-10-24-self-hosting-secret-checks.mdx b/apps/web/content/changelog/web/2026-10-24-self-hosting-secret-checks.mdx new file mode 100644 index 00000000000..f22c7fca19e --- /dev/null +++ b/apps/web/content/changelog/web/2026-10-24-self-hosting-secret-checks.mdx @@ -0,0 +1,12 @@ +--- +title: Required Secrets for Self-Hosted Upgrades +publishedAt: "2026-10-24" +--- + +Self-hosted Cap now refuses to start with missing secrets or the public example values shipped in older Compose files. Generate unique `NEXTAUTH_SECRET`, `DATABASE_ENCRYPTION_KEY` and `MEDIA_SERVER_WEBHOOK_SECRET` values before a new installation. Use the same webhook secret on cap-web and the media server. Coolify already generates unique secrets per deployment. + +## Upgrading an Existing Installation + +Back up MySQL and read the [upgrade instructions](/docs/self-hosting#upgrading-from-an-older-release) before changing secrets. Update the repository and any copied Compose template, then pull images and rebuild the media server from the updated source. Adding values to `.env` does not replace secrets hardcoded in an older template. + +Changing `DATABASE_ENCRYPTION_KEY` makes existing encrypted storage settings, developer app keys and integration credentials unreadable. To upgrade before migrating that data, retain the exact existing valid encryption key and explicitly set `CAP_ALLOW_INSECURE_SECRETS=true` on cap-web. This temporary override logs a warning on every affected startup and keeps missing-secret and key-format checks enforced. Rotate authentication and webhook secrets, plan the encrypted-data migration, and remove the override after rotating the encryption key. diff --git a/apps/web/content/docs/self-hosting.mdx b/apps/web/content/docs/self-hosting.mdx index 32e16f0f975..fb9185dc397 100644 --- a/apps/web/content/docs/self-hosting.mdx +++ b/apps/web/content/docs/self-hosting.mdx @@ -10,20 +10,25 @@ You can upload videos to it from the dashboard or from Cap Desktop. -## Quick Start (One Command) +## Quick Start -The fastest way to self-host Cap is with Docker Compose. This single command will start everything you need: +The fastest way to self-host Cap is with Docker Compose. Generate your secrets, then start the services: ```bash git clone https://github.com/CapSoftware/Cap.git cd Cap +cp .env.self-hosting.example .env +for name in NEXTAUTH_SECRET DATABASE_ENCRYPTION_KEY MEDIA_SERVER_WEBHOOK_SECRET; do + sed -i.bak "s/^$name=.*/$name=$(openssl rand -hex 32)/" .env +done +rm .env.bak docker compose up -d ``` That's it! Cap will be available at `http://localhost:3000`. - -The default `docker-compose.yml` includes placeholder secrets for quick local testing. **If you're deploying to a public server or production environment**, you must change these values. See the [Production Checklist](#production-checklist) below. + +`docker compose up` stops with an error until `NEXTAUTH_SECRET`, `DATABASE_ENCRYPTION_KEY` and `MEDIA_SERVER_WEBHOOK_SECRET` are set. cap-web and the media server also refuse to start if any of them is left at a publicly known placeholder, including the example values from older compose files. Upgrading an existing install? Read [Upgrading From an Older Release](#upgrading-from-an-older-release) first. The Quick Start above creates `.env` and generates a unique value for each required secret. Login links will appear in the logs since email isn't configured: @@ -45,8 +50,9 @@ Best for VPS, home servers, or any Docker-capable host. **Steps:** 1. Clone the repository -2. Run `docker compose up -d` -3. Access Cap at `http://localhost:3000` +2. Create `.env` and generate the three secrets using the Quick Start commands above. +3. Run `docker compose up -d`. +4. Access Cap at `http://localhost:3000` > **Note:** The main `docker-compose.yml` builds the media server from source, so a full clone of the repository is required. The Coolify compose file uses a pre-built image from `ghcr.io` instead. @@ -192,16 +198,49 @@ APPLE_CLIENT_SECRET=your-client-secret-jwt Apple client-secret JWTs expire, so rotate the secret before its configured expiration date. +## Upgrading From an Older Release + + +Older `docker-compose.yml` and `docker-compose.template.yml` files shipped fixed example secrets. Coolify generates unique secrets per deployment and does not require rotation for this change. Starting with this release, **cap-web and the media server refuse to start** if any of these is missing or still set to one of those public example values: + +- `NEXTAUTH_SECRET` (cap-web) +- `DATABASE_ENCRYPTION_KEY` (cap-web) +- `MEDIA_SERVER_WEBHOOK_SECRET` (cap-web and the media server; it must be the same on both) + +With Docker Compose, `docker compose up` stops before starting any container and names the missing variable. Otherwise the container exits on start with `Refusing to start: insecure secrets` (cap-web) or `[media-server] Refusing to start` in its logs. + + +If you never changed the example secrets, do this before you pull the new images: + +1. Back up MySQL. +2. Generate a new value for each secret with `openssl rand -hex 32` and put them in the `.env` next to your compose file (or in your platform's variables). Use the same `MEDIA_SERVER_WEBHOOK_SECRET` on cap-web and the media server. +3. Update the repository with `git pull`. If you use a copy of `docker-compose.template.yml`, replace the three hardcoded secret values in your copy with the `${VARIABLE:?Set VARIABLE in .env}` entries from the updated template (or switch to the new template). Values in `.env` do not override hardcoded Compose environment values. +4. Pull the web image, rebuild the media server from the updated source, and restart: `docker compose pull && docker compose up -d --build`. When using a custom template filename, pass `-f your-compose.yml` to each Compose command. + +What changes when you replace each secret: + +- `NEXTAUTH_SECRET`: everyone is signed out and signs in again. +- `MEDIA_SERVER_WEBHOOK_SECRET`: nothing, as long as cap-web and the media server get the same new value. +- `DATABASE_ENCRYPTION_KEY`: values Cap encrypted with the old key can no longer be read. This covers custom S3 storage settings (organization and desktop), developer app API keys, and connected integrations. After switching keys, re-enter your storage settings and regenerate developer app keys. + +### Temporarily Keeping the Old Encryption Key + +If you need to upgrade before migrating encrypted data, keep the **exact existing** `DATABASE_ENCRYPTION_KEY` in `.env` and set `CAP_ALLOW_INSECURE_SECRETS=true`. The updated Compose files forward this flag to cap-web. For other platforms, set it directly on cap-web. Rotate `NEXTAUTH_SECRET` and `MEDIA_SERVER_WEBHOOK_SECRET` as described above; the media server still requires a unique webhook secret. + +This opt-in permits known placeholder values but does not bypass missing-secret or encryption-key format checks. cap-web logs a prominent warning every time it starts with a placeholder. The existing key keeps encrypted storage settings, developer app keys and integrations readable, but a publicly known key leaves that data exposed. Treat this as a temporary upgrade measure: plan the data migration or reconfiguration, rotate the key, then remove `CAP_ALLOW_INSECURE_SECRETS` and restart. Do not generate a new encryption key until you are ready for the encrypted-data consequences described above. + +If you already set your own values, nothing changes for you. + ## Production Checklist - -The default `docker-compose.yml` contains **hardcoded placeholder secrets** that are visible in the public repository. Anyone who knows you're using Cap with defaults could potentially: + +`docker-compose.yml` no longer ships default secrets, and Cap refuses to start with the old public placeholders. Anyone who knows a deployment's secrets could: - **Forge authentication sessions** (via `NEXTAUTH_SECRET`) - **Decrypt sensitive database fields** (via `DATABASE_ENCRYPTION_KEY`) - **Spoof webhook requests** (via `MEDIA_SERVER_WEBHOOK_SECRET`) -This is fine for local development or testing on a private network, but **you must generate unique secrets before exposing Cap to the internet**. +Generate unique values even for testing. **Generate secure secrets:** diff --git a/apps/web/instrumentation.node.ts b/apps/web/instrumentation.node.ts index 5b4382028ba..c3f30b43e8a 100644 --- a/apps/web/instrumentation.node.ts +++ b/apps/web/instrumentation.node.ts @@ -9,7 +9,13 @@ import { S3Client, } from "@aws-sdk/client-s3"; import { migrateDb } from "@cap/database/migrate"; -import { buildEnv, serverEnv } from "@cap/env"; +import { + buildEnv, + findInsecureSecrets, + formatInsecureSecrets, + REQUIRED_SELF_HOST_SECRETS, + serverEnv, +} from "@cap/env"; export async function register() { if (process.env.NEXT_PUBLIC_IS_CAP) return; @@ -22,6 +28,28 @@ export async function register() { process.exit(1); } + if (process.env.NODE_ENV === "production") { + const insecureSecrets = findInsecureSecrets( + process.env, + process.env.MEDIA_SERVER_URL || process.env.MEDIA_SERVER_WEBHOOK_SECRET + ? REQUIRED_SELF_HOST_SECRETS + : ["NEXTAUTH_SECRET", "DATABASE_ENCRYPTION_KEY"], + ); + if (insecureSecrets.length > 0) { + if ( + process.env.CAP_ALLOW_INSECURE_SECRETS === "true" && + insecureSecrets.every(({ problem }) => problem === "placeholder") + ) { + console.warn( + `🚨 CAP_ALLOW_INSECURE_SECRETS=true: starting with publicly known secrets (${insecureSecrets.map(({ name }) => name).join(", ")}). This temporary upgrade override leaves your deployment insecure. Rotate these secrets and remove the override as soon as possible.`, + ); + } else { + console.error(`🚨 ${formatInsecureSecrets(insecureSecrets)}`); + process.exit(1); + } + } + } + console.log("Waiting 5 seconds to run migrations"); // Function to trigger migrations with retry logic const triggerMigrations = async (retryCount = 0, maxRetries = 3) => { diff --git a/docker-compose.template.yml b/docker-compose.template.yml index 1360b3d8715..0a57e7efc18 100644 --- a/docker-compose.template.yml +++ b/docker-compose.template.yml @@ -14,8 +14,9 @@ services: WEB_URL: http://localhost:3000 NEXTAUTH_URL: http://localhost:3000 # CHANGE THESE TO YOUR OWN VALUES - DATABASE_ENCRYPTION_KEY: c7a1e98e1e5e4cec5a3fbf5eaf502d262fb99807ae2be8ee70537409e29cb6f9 - NEXTAUTH_SECRET: c7a1e98e1e5e4cec5a3fbf5eaf502d262fb99807ae2be8ee70537409e29cb6f9 + DATABASE_ENCRYPTION_KEY: "${DATABASE_ENCRYPTION_KEY:?Set DATABASE_ENCRYPTION_KEY (run: openssl rand -hex 32)}" + NEXTAUTH_SECRET: "${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET (run: openssl rand -hex 32)}" + CAP_ALLOW_INSECURE_SECRETS: ${CAP_ALLOW_INSECURE_SECRETS:-false} # CHANGE THESE TO MATCH YOUR MINIO or S3 STORAGE SETUP CAP_AWS_ACCESS_KEY: capS3root CAP_AWS_SECRET_KEY: capS3root @@ -25,7 +26,7 @@ services: S3_INTERNAL_ENDPOINT: http://minio:3902 MEDIA_SERVER_URL: http://cap-media-server:3456 # CHANGE THESE TO YOUR OWN VALUES - MEDIA_SERVER_WEBHOOK_SECRET: fe337b52749070bb7b5d2c78cff9948439ea73cbc1869ba39d350e6c24db53b1 + MEDIA_SERVER_WEBHOOK_SECRET: "${MEDIA_SERVER_WEBHOOK_SECRET:?Set MEDIA_SERVER_WEBHOOK_SECRET (run: openssl rand -hex 32)}" ports: - 3000:3000 @@ -57,7 +58,7 @@ services: restart: unless-stopped environment: PORT: 3456 - MEDIA_SERVER_WEBHOOK_SECRET: fe337b52749070bb7b5d2c78cff9948439ea73cbc1869ba39d350e6c24db53b1 + MEDIA_SERVER_WEBHOOK_SECRET: "${MEDIA_SERVER_WEBHOOK_SECRET:?Set MEDIA_SERVER_WEBHOOK_SECRET (run: openssl rand -hex 32)}" ports: - "3456:3456" healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 98badf02671..fef3433c070 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,10 @@ services: DATABASE_URL: mysql://cap:${MYSQL_PASSWORD:-cap-local-pwd-123}@mysql:3306/cap WEB_URL: ${CAP_URL:-http://localhost:3000} NEXTAUTH_URL: ${CAP_URL:-http://localhost:3000} - DATABASE_ENCRYPTION_KEY: ${DATABASE_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef} - NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789} - MEDIA_SERVER_WEBHOOK_SECRET: ${MEDIA_SERVER_WEBHOOK_SECRET:-fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210} + DATABASE_ENCRYPTION_KEY: "${DATABASE_ENCRYPTION_KEY:?Set DATABASE_ENCRYPTION_KEY in .env (run: openssl rand -hex 32)}" + NEXTAUTH_SECRET: "${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET in .env (run: openssl rand -hex 32)}" + CAP_ALLOW_INSECURE_SECRETS: ${CAP_ALLOW_INSECURE_SECRETS:-false} + MEDIA_SERVER_WEBHOOK_SECRET: "${MEDIA_SERVER_WEBHOOK_SECRET:?Set MEDIA_SERVER_WEBHOOK_SECRET in .env (run: openssl rand -hex 32)}" CAP_AWS_ACCESS_KEY: ${MINIO_ROOT_USER:-cap-admin} CAP_AWS_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-cap-minio-pwd-456} CAP_AWS_BUCKET: cap @@ -53,7 +54,7 @@ services: restart: unless-stopped environment: PORT: 3456 - MEDIA_SERVER_WEBHOOK_SECRET: ${MEDIA_SERVER_WEBHOOK_SECRET:-fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210} + MEDIA_SERVER_WEBHOOK_SECRET: "${MEDIA_SERVER_WEBHOOK_SECRET:?Set MEDIA_SERVER_WEBHOOK_SECRET in .env (run: openssl rand -hex 32)}" healthcheck: test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3456/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s diff --git a/packages/env/index.ts b/packages/env/index.ts index 815f62a2ee8..e10c3b82b1b 100644 --- a/packages/env/index.ts +++ b/packages/env/index.ts @@ -1,2 +1,8 @@ export { buildEnv, NODE_ENV } from "./build.ts"; +export { + findInsecureSecrets, + formatInsecureSecrets, + KNOWN_PLACEHOLDER_SECRETS, + REQUIRED_SELF_HOST_SECRETS, +} from "./secrets.ts"; export { serverEnv } from "./server.ts"; diff --git a/packages/env/secrets.ts b/packages/env/secrets.ts new file mode 100644 index 00000000000..ab7ce8917cc --- /dev/null +++ b/packages/env/secrets.ts @@ -0,0 +1,52 @@ +export const REQUIRED_SELF_HOST_SECRETS = [ + "NEXTAUTH_SECRET", + "DATABASE_ENCRYPTION_KEY", + "MEDIA_SERVER_WEBHOOK_SECRET", +] as const; + +export type RequiredSelfHostSecret = + (typeof REQUIRED_SELF_HOST_SECRETS)[number]; + +export const KNOWN_PLACEHOLDER_SECRETS: ReadonlySet = new Set([ + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "c7a1e98e1e5e4cec5a3fbf5eaf502d262fb99807ae2be8ee70537409e29cb6f9", + "fe337b52749070bb7b5d2c78cff9948439ea73cbc1869ba39d350e6c24db53b1", + "local-media-server-secret", + "changeme", + "change-me", + "secret", +]); + +export type InsecureSecret = { + name: RequiredSelfHostSecret; + problem: "missing" | "placeholder"; +}; + +export function findInsecureSecrets( + env: Record, + names: readonly RequiredSelfHostSecret[] = REQUIRED_SELF_HOST_SECRETS, +): InsecureSecret[] { + const problems: InsecureSecret[] = []; + for (const name of names) { + const value = env[name]?.trim(); + if (!value) problems.push({ name, problem: "missing" }); + else if (KNOWN_PLACEHOLDER_SECRETS.has(value.toLowerCase())) + problems.push({ name, problem: "placeholder" }); + } + return problems; +} + +export function formatInsecureSecrets(problems: InsecureSecret[]): string { + const lines = problems.map( + ({ name, problem }) => + ` - ${name} is ${problem === "missing" ? "not set" : "set to a publicly known placeholder value"}`, + ); + return [ + "Refusing to start: insecure secrets.", + ...lines, + "Generate a unique value for each with `openssl rand -hex 32` and set it in your environment (.env / platform variables).", + "MEDIA_SERVER_WEBHOOK_SECRET must be identical on cap-web and the media server.", + ].join("\n"); +} diff --git a/packages/local-docker/docker-compose.yml b/packages/local-docker/docker-compose.yml index 5f250568836..a3239e05556 100644 --- a/packages/local-docker/docker-compose.yml +++ b/packages/local-docker/docker-compose.yml @@ -30,6 +30,7 @@ services: environment: PORT: 3456 MEDIA_SERVER_WEBHOOK_SECRET: ${MEDIA_SERVER_WEBHOOK_SECRET:-local-media-server-secret} + MEDIA_SERVER_ALLOW_INSECURE_SECRET: "true" # Local S3 Strorage minio: