Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions server/lib/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getPasteAuthSecret,
getPublicUrl,
getTrustedProxyHops,
isWeakPasteAuthSecret,
} from "./env";

const keys = [
Expand Down Expand Up @@ -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", () => {
Expand Down
37 changes: 35 additions & 2 deletions server/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
Loading