From a3ce8664808fd8f84d74f7282ce9987d99b13186 Mon Sep 17 00:00:00 2001 From: ArianAr Date: Thu, 16 Jul 2026 02:28:49 +0300 Subject: [PATCH] fix(security): reject weak PASTE_AUTH_SECRET in production Refuse empty, short, and documented placeholder secrets so unlock cookies cannot be forged on default Compose deploys. Require an explicit PASTE_AUTH_SECRET in docker-compose. Closes #65 --- CHANGELOG.md | 1 + docker-compose.yml | 3 ++- server/lib/env.test.ts | 25 ++++++++++++++++++++++--- server/lib/env.ts | 37 +++++++++++++++++++++++++++++++++++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24cdb12..f29e9ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Security:** production rejects empty/known-placeholder/`<16` char `PASTE_AUTH_SECRET`; Compose no longer ships a working default secret - Theme FOUC boot script is a static string (no `JSON.stringify` interpolation) to clear CodeQL `js/bad-code-sanitization` - CI workflow sets explicit `permissions: contents: read` (CodeQL `actions/missing-workflow-permissions`) - `stripAnsi` uses a linear scan instead of nested quantifier regexes (CodeQL `js/polynomial-redos`) diff --git a/docker-compose.yml b/docker-compose.yml index 19a9351..fcc9692 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,8 @@ services: environment: PAPERCUT_PUBLIC_URL: ${PAPERCUT_PUBLIC_URL:-http://localhost:3000} DATABASE_PATH: /data/papercut.db - PASTE_AUTH_SECRET: ${PASTE_AUTH_SECRET:-change-me-in-production-use-a-long-random-string} + # Required: no default — weak placeholders are rejected at runtime in production. + PASTE_AUTH_SECRET: ${PASTE_AUTH_SECRET:?Set PASTE_AUTH_SECRET to a long random value (e.g. openssl rand -hex 32)} MAX_PASTE_SIZE: ${MAX_PASTE_SIZE:-10485760} TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-1} NODE_ENV: production diff --git a/server/lib/env.test.ts b/server/lib/env.test.ts index 0a0d00a..0d227f8 100644 --- a/server/lib/env.test.ts +++ b/server/lib/env.test.ts @@ -5,6 +5,7 @@ import { getPasteAuthSecret, getPublicUrl, getTrustedProxyHops, + isWeakPasteAuthSecret, } from "./env"; const keys = [ @@ -52,15 +53,33 @@ describe("env helpers", () => { expect(getPublicUrl()).toBeUndefined(); }); - it("requires auth secret in production", () => { + it("requires a strong auth secret in production", () => { remember(); vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("PASTE_AUTH_SECRET", ""); delete process.env.PASTE_AUTH_SECRET; expect(() => getPasteAuthSecret()).toThrow(/PASTE_AUTH_SECRET/); - vi.stubEnv("PASTE_AUTH_SECRET", "prod-secret"); - expect(getPasteAuthSecret()).toBe("prod-secret"); + vi.stubEnv( + "PASTE_AUTH_SECRET", + "change-me-in-production-use-a-long-random-string", + ); + expect(() => getPasteAuthSecret()).toThrow(/strong random|placeholder/i); + + vi.stubEnv("PASTE_AUTH_SECRET", "short"); + expect(() => getPasteAuthSecret()).toThrow(/PASTE_AUTH_SECRET/); + + vi.stubEnv("PASTE_AUTH_SECRET", "prod-secret-long-enough"); + expect(getPasteAuthSecret()).toBe("prod-secret-long-enough"); + }); + + it("detects weak placeholder secrets", () => { + expect(isWeakPasteAuthSecret(undefined)).toBe(true); + expect(isWeakPasteAuthSecret("")).toBe(true); + expect( + isWeakPasteAuthSecret("change-me-to-a-long-random-string"), + ).toBe(true); + expect(isWeakPasteAuthSecret("prod-secret-long-enough")).toBe(false); }); it("defaults trusted proxy hops to 1", () => { diff --git a/server/lib/env.ts b/server/lib/env.ts index 8ae3191..925e516 100644 --- a/server/lib/env.ts +++ b/server/lib/env.ts @@ -23,12 +23,45 @@ export function getPublicUrl(): string | undefined { return url ? url.replace(/\/$/, "") : undefined; } +/** Known placeholders that must never be used as production unlock secrets. */ +const WEAK_PASTE_AUTH_SECRETS = new Set([ + "change-me-in-production-use-a-long-random-string", + "change-me-to-a-long-random-string", + "papercut-dev-only-secret-change-me", + "changeme", + "secret", + "password", +]); + +const MIN_PROD_AUTH_SECRET_LEN = 16; + +/** + * True when a secret is empty, a known placeholder, or too short for production. + * Used so Compose/docs placeholders cannot ship as working unlock keys. + */ +export function isWeakPasteAuthSecret(secret: string | undefined): boolean { + if (secret == null) return true; + const s = secret.trim(); + if (!s) return true; + if (WEAK_PASTE_AUTH_SECRETS.has(s.toLowerCase())) return true; + if (WEAK_PASTE_AUTH_SECRETS.has(s)) return true; + if (s.length < MIN_PROD_AUTH_SECRET_LEN) return true; + return false; +} + export function getPasteAuthSecret(): string { const secret = process.env.PASTE_AUTH_SECRET?.trim(); - if (secret) return secret; if (process.env.NODE_ENV === "production") { - throw new Error("PASTE_AUTH_SECRET is required in production"); + if (!secret || isWeakPasteAuthSecret(secret)) { + throw new Error( + "PASTE_AUTH_SECRET must be a strong random value in production " + + `(≥${MIN_PROD_AUTH_SECRET_LEN} chars; not a documented placeholder). ` + + 'Generate one with: openssl rand -hex 32', + ); + } + return secret; } + if (secret && !isWeakPasteAuthSecret(secret)) return secret; // Deterministic dev fallback so cookies work across reloads; not for production. return "papercut-dev-only-secret-change-me"; }