diff --git a/.env.example b/.env.example index a82723e..849eabb 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,8 @@ CREATE_RATE_WINDOW_MS=600000 # Set to 1 / true / yes / on to enable. Counters only — no content or IPs. # PAPERCUT_METRICS=0 -# Reverse proxy: how many hops to trust for X-Forwarded-For (default 1) +# Reverse proxy: hops to trust for X-Forwarded-For (default 0 = ignore XFF). +# Set to 1 behind a single nginx/Caddy/Traefik so rate limits use real clients. # TRUSTED_PROXY_HOPS=1 # Force Secure cookies (default: derive from PAPERCUT_PUBLIC_URL / NODE_ENV) # COOKIE_SECURE=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f29e9ba..324d0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,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 +- **Security:** default `TRUSTED_PROXY_HOPS=0` (ignore spoofable XFF on direct deploys); in-memory rate limiters prune/evict keys; set hops `1` behind a reverse proxy - 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/README.md b/README.md index 078e611..aac2ba9 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ See [`.env.example`](./.env.example). | `CREATE_RATE_LIMIT` | Creates / window | `60` | | `CREATE_RATE_WINDOW_MS` | Create window (ms) | `600000` | | `PAPERCUT_METRICS` | Enable `GET /api/metrics` (`1`/`true`/`yes`/`on`) | off | -| `TRUSTED_PROXY_HOPS` | X-Forwarded-For hops to trust (0 = ignore XFF) | `1` | +| `TRUSTED_PROXY_HOPS` | X-Forwarded-For hops to trust (0 = ignore XFF; use `1` behind a proxy) | `0` | | `COOKIE_SECURE` | Force Secure cookies (`1`/`0`); else from public URL | auto | | `REDIS_URL` | Shared rate limits across instances (optional) | off (in-memory) | diff --git a/SECURITY.md b/SECURITY.md index e7f873a..7396a07 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -75,7 +75,7 @@ PaperCut is designed with developer privacy in mind: - **Password-protected pastes** store a password hash only; content is not returned until unlock succeeds - **Rate limits** use in-memory counters keyed by client hints (e.g. `X-Forwarded-For`); keys are not written to application logs - **Opt-in metrics** (`PAPERCUT_METRICS`) expose process counters only (paste creates, successful unlocks, rate-limit 429s) via `GET /api/metrics`; disabled by default; never includes paste bodies, IPs, or rate-limit keys -- **Trusted proxy hops** (`TRUSTED_PROXY_HOPS`, default 1) select the client key from `X-Forwarded-For` from the right so a single reverse proxy’s real client address is used; keys stay in memory only +- **Trusted proxy hops** (`TRUSTED_PROXY_HOPS`, default **0** = ignore XFF) so direct deploys cannot bypass rate limits via spoofed `X-Forwarded-For`. Set to `1` behind a single reverse proxy so the real client address is used; keys stay in memory only - **Self-hosting** is the primary deployment model—you control the data plane Sensitive logs or credentials should always use **private** (password-protected) pastes and short expiry when possible. A public paste ID is effectively a capability URL: anyone with the link can read a non-private paste. diff --git a/docker-compose.yml b/docker-compose.yml index fcc9692..1043507 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ # export PAPERCUT_DOMAIN=paste.example.com # export PAPERCUT_PUBLIC_URL=https://paste.example.com # export PASTE_AUTH_SECRET="$(openssl rand -hex 32)" +# export TRUSTED_PROXY_HOPS=1 # required so rate limits use real client IPs # docker compose --profile proxy up --build -d # # Prefer firewall: only 80/443 public; optional PAPERCUT_HOST_PORT= to avoid publishing 3000 # @@ -26,7 +27,8 @@ services: # 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} + # Default 0: ignore XFF on direct :3000 publish. Set 1 when using profile `proxy`. + TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-0} NODE_ENV: production volumes: - papercut-data:/data diff --git a/docs/deploy/README.md b/docs/deploy/README.md index d76eee2..8d8dec3 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -42,6 +42,8 @@ PaperCut does **not** terminate TLS itself. Put a reverse proxy in front for HTT export PASTE_AUTH_SECRET="$(openssl rand -hex 32)" export PAPERCUT_DOMAIN="paste.example.com" export PAPERCUT_PUBLIC_URL="https://paste.example.com" +# Trust one reverse-proxy hop so rate limits use the real client (not "local") +export TRUSTED_PROXY_HOPS=1 # Optional: do not publish app port on the host (proxy-only) export PAPERCUT_HOST_PORT="" docker compose --profile proxy up --build -d @@ -204,7 +206,7 @@ Configure Traefik entrypoints `web`/`websecure` and a Let's Encrypt certificate | `PASTE_AUTH_SECRET` | Long random secret (required in production) | | `DATABASE_PATH` | e.g. `/data/papercut.db` | | `PAPERCUT_METRICS` | Optional: `1` to enable `GET /api/metrics` (counters only; off by default) | -| `TRUSTED_PROXY_HOPS` | Usually `1` behind a single reverse proxy (default). Use `0` only if you do not forward XFF. | +| `TRUSTED_PROXY_HOPS` | Set to `1` behind a single reverse proxy. Default is `0` (ignore XFF) for safe direct deploys. | | `COOKIE_SECURE` | Optional override; with `PAPERCUT_PUBLIC_URL=https://…` unlock cookies are Secure automatically | | Proxy headers | `X-Forwarded-For`, `X-Forwarded-Proto` | diff --git a/server/lib/env.test.ts b/server/lib/env.test.ts index 0d227f8..97a3ad0 100644 --- a/server/lib/env.test.ts +++ b/server/lib/env.test.ts @@ -82,12 +82,14 @@ describe("env helpers", () => { expect(isWeakPasteAuthSecret("prod-secret-long-enough")).toBe(false); }); - it("defaults trusted proxy hops to 1", () => { + it("defaults trusted proxy hops to 0 (ignore XFF)", () => { remember(); delete process.env.TRUSTED_PROXY_HOPS; - expect(getTrustedProxyHops()).toBe(1); + expect(getTrustedProxyHops()).toBe(0); process.env.TRUSTED_PROXY_HOPS = "2"; expect(getTrustedProxyHops()).toBe(2); + process.env.TRUSTED_PROXY_HOPS = "1"; + expect(getTrustedProxyHops()).toBe(1); process.env.TRUSTED_PROXY_HOPS = "0"; expect(getTrustedProxyHops()).toBe(0); }); diff --git a/server/lib/env.ts b/server/lib/env.ts index 925e516..ec753b0 100644 --- a/server/lib/env.ts +++ b/server/lib/env.ts @@ -68,11 +68,12 @@ export function getPasteAuthSecret(): string { /** * How many reverse-proxy hops to trust when reading X-Forwarded-For. - * Default 1 (single nginx/Caddy/Traefik). Set 0 to ignore XFF entirely. + * Default 0 (ignore XFF) so direct deploys cannot have rate limits bypassed + * by spoofed X-Forwarded-For. Set to 1 behind a single nginx/Caddy/Traefik. */ export function getTrustedProxyHops(): number { const raw = process.env.TRUSTED_PROXY_HOPS; - if (raw === undefined || raw.trim() === "") return 1; + if (raw === undefined || raw.trim() === "") return 0; const n = Number.parseInt(raw, 10); if (!Number.isFinite(n) || n < 0) { throw new Error(`Invalid TRUSTED_PROXY_HOPS: ${raw}`); diff --git a/server/lib/rate-limit.test.ts b/server/lib/rate-limit.test.ts index 90fbb54..b42c9d6 100644 --- a/server/lib/rate-limit.test.ts +++ b/server/lib/rate-limit.test.ts @@ -47,6 +47,34 @@ describe("RateLimiter", () => { expect(limiter.attempt("one").allowed).toBe(false); expect(limiter.attempt("two").allowed).toBe(true); }); + + it("prune drops keys with only expired hits", () => { + let now = 1_000_000; + const limiter = new RateLimiter({ + limit: 5, + windowMs: 1_000, + now: () => now, + }); + expect(limiter.attempt("stale").allowed).toBe(true); + expect(limiter.size()).toBe(1); + now += 5_000; + limiter.prune(); + expect(limiter.size()).toBe(0); + }); + + it("evicts when maxKeys is exceeded", () => { + const limiter = new RateLimiter({ + limit: 10, + windowMs: 60_000, + maxKeys: 3, + }); + expect(limiter.attempt("a").allowed).toBe(true); + expect(limiter.attempt("b").allowed).toBe(true); + expect(limiter.attempt("c").allowed).toBe(true); + expect(limiter.size()).toBe(3); + expect(limiter.attempt("d").allowed).toBe(true); + expect(limiter.size()).toBe(3); + }); }); describe("clientKeyFromRequest", () => { diff --git a/server/lib/rate-limit.ts b/server/lib/rate-limit.ts index c9e2de8..cefed0f 100644 --- a/server/lib/rate-limit.ts +++ b/server/lib/rate-limit.ts @@ -23,16 +23,24 @@ export interface RateLimiterOptions { export type RateLimitScope = "create" | "unlock"; +/** Soft cap on distinct client keys held in memory (evict expired, then oldest). */ +const DEFAULT_MAX_KEYS = 50_000; +/** Prune expired keys every N attempts to bound memory under key churn. */ +const PRUNE_EVERY_ATTEMPTS = 64; + export class RateLimiter { private readonly hits = new Map(); private readonly limit: number; private readonly windowMs: number; private readonly now: () => number; + private readonly maxKeys: number; + private attemptCount = 0; - constructor(options: RateLimiterOptions) { + constructor(options: RateLimiterOptions & { maxKeys?: number }) { this.limit = options.limit; this.windowMs = options.windowMs; this.now = options.now ?? Date.now; + this.maxKeys = options.maxKeys ?? DEFAULT_MAX_KEYS; } /** @@ -40,6 +48,11 @@ export class RateLimiter { * Call this for every attempt you want to count (e.g. failed unlocks). */ attempt(key: string): RateLimitResult { + this.attemptCount += 1; + if (this.attemptCount % PRUNE_EVERY_ATTEMPTS === 0) { + this.prune(); + } + const now = this.now(); const windowStart = now - this.windowMs; const prev = this.hits.get(key) ?? []; @@ -57,6 +70,7 @@ export class RateLimiter { recent.push(now); this.hits.set(key, recent); + this.evictIfOverCap(); return { allowed: true, retryAfterSec: 0, @@ -66,18 +80,36 @@ export class RateLimiter { /** Test helper */ reset(key?: string): void { - if (key === undefined) this.hits.clear(); - else this.hits.delete(key); + if (key === undefined) { + this.hits.clear(); + this.attemptCount = 0; + } else this.hits.delete(key); + } + + /** Number of tracked keys (test helper). */ + size(): number { + return this.hits.size; } /** Bound memory: drop empty/expired keys periodically */ prune(): void { const now = this.now(); const windowStart = now - this.windowMs; - for (const [key, times] of this.hits) { + for (const [k, times] of this.hits) { const recent = times.filter((t) => t > windowStart); - if (recent.length === 0) this.hits.delete(key); - else this.hits.set(key, recent); + if (recent.length === 0) this.hits.delete(k); + else this.hits.set(k, recent); + } + } + + /** If still over cap after prune, drop arbitrary oldest Map entries (FIFO). */ + private evictIfOverCap(): void { + if (this.hits.size <= this.maxKeys) return; + this.prune(); + while (this.hits.size > this.maxKeys) { + const oldestKey = this.hits.keys().next().value; + if (oldestKey === undefined) break; + this.hits.delete(oldestKey); } } }