From 8ca0ec0ac85e489baa793411f468b24c5e9b58a8 Mon Sep 17 00:00:00 2001 From: ArianAr Date: Thu, 16 Jul 2026 01:08:29 +0300 Subject: [PATCH] feat(server): proxy-aware rate-limit hops and Secure cookies TRUSTED_PROXY_HOPS (default 1) picks the client from X-Forwarded-For from the right. Unlock cookie Secure derives from PAPERCUT_PUBLIC_URL or COOKIE_SECURE. Closes #39 --- .env.example | 5 +++++ CHANGELOG.md | 1 + README.md | 2 ++ ROADMAP.md | 2 +- SECURITY.md | 1 + docs/deploy/README.md | 2 ++ server/lib/auth-cookie.ts | 4 ++-- server/lib/env.test.ts | 37 ++++++++++++++++++++++++++++++++++- server/lib/env.ts | 37 +++++++++++++++++++++++++++++++++++ server/lib/rate-limit.test.ts | 29 +++++++++++++++++++++++++-- server/lib/rate-limit.ts | 20 ++++++++++++++++--- 11 files changed, 131 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 3361837..52f2cb4 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ CREATE_RATE_WINDOW_MS=600000 # Opt-in process metrics (GET /api/metrics). Off by default. # 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) +# 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 209ce16..a253bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Log canvas **custom highlight rules** (regex → color, per-browser `localStorage`) - Log canvas **timeline scrubber** for timestamped logs (ISO / date-time / syslog / epoch) - Log canvas **multi-paste compare** (side-by-side via `?compare=` or sidebar) +- Proxy-aware runtime: `TRUSTED_PROXY_HOPS` for rate-limit client keys; Secure cookies from `PAPERCUT_PUBLIC_URL` / `COOKIE_SECURE` ### Fixed diff --git a/README.md b/README.md index aa5b7b8..5e91469 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ 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` | +| `COOKIE_SECURE` | Force Secure cookies (`1`/`0`); else from public URL | auto | --- diff --git a/ROADMAP.md b/ROADMAP.md index 3106871..e45cfde 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ Focus: multi-instance self-host without losing privacy defaults. | 1.3.4 | **Background expire sweeper** (cron process) | server | Complement purge-on-read | | 1.3.5 | **Read replicas / RO mode** | server | Optional | | 1.3.6 | **Optional reverse-proxy stack** (compose profiles) | docker | nginx / Caddy / Traefik + ACME for domain + HTTPS; app still plain HTTP behind proxy | -| 1.3.7 | **Proxy-aware runtime** | server | Document/trust hop config for rate limits & secure cookies behind TLS terminators | +| 1.3.7 | **Proxy-aware runtime** | server | ✅ Done (`TRUSTED_PROXY_HOPS`, `COOKIE_SECURE` / public URL) | ### Reverse proxy & HTTPS (intent) diff --git a/SECURITY.md b/SECURITY.md index 7122785..e7f873a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -75,6 +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 - **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/docs/deploy/README.md b/docs/deploy/README.md index 299e3ec..635d56c 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -183,6 +183,8 @@ 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. | +| `COOKIE_SECURE` | Optional override; with `PAPERCUT_PUBLIC_URL=https://…` unlock cookies are Secure automatically | | Proxy headers | `X-Forwarded-For`, `X-Forwarded-Proto` | Firewall: expose **80/443** publicly; keep **3000** localhost-only or Docker-internal when using a proxy. diff --git a/server/lib/auth-cookie.ts b/server/lib/auth-cookie.ts index cbc6b72..961b46c 100644 --- a/server/lib/auth-cookie.ts +++ b/server/lib/auth-cookie.ts @@ -1,5 +1,5 @@ import { createHmac, timingSafeEqual } from "node:crypto"; -import { getPasteAuthSecret } from "./env"; +import { getCookieSecure, getPasteAuthSecret } from "./env"; const COOKIE_PREFIX = "pc_auth_"; const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h @@ -64,7 +64,7 @@ export function unlockCookieOptions(maxAgeSeconds = 24 * 60 * 60) { return { httpOnly: true, sameSite: "lax" as const, - secure: process.env.NODE_ENV === "production", + secure: getCookieSecure(), path: "/", maxAge: maxAgeSeconds, }; diff --git a/server/lib/env.test.ts b/server/lib/env.test.ts index 312e3b8..0a0d00a 100644 --- a/server/lib/env.test.ts +++ b/server/lib/env.test.ts @@ -1,10 +1,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getMaxPasteSize, getPasteAuthSecret, getPublicUrl } from "./env"; +import { + getCookieSecure, + getMaxPasteSize, + getPasteAuthSecret, + getPublicUrl, + getTrustedProxyHops, +} from "./env"; const keys = [ "MAX_PASTE_SIZE", "PASTE_AUTH_SECRET", "PAPERCUT_PUBLIC_URL", + "TRUSTED_PROXY_HOPS", + "COOKIE_SECURE", ] as const; const snapshot: Partial> = {}; @@ -54,4 +62,31 @@ describe("env helpers", () => { vi.stubEnv("PASTE_AUTH_SECRET", "prod-secret"); expect(getPasteAuthSecret()).toBe("prod-secret"); }); + + it("defaults trusted proxy hops to 1", () => { + remember(); + delete process.env.TRUSTED_PROXY_HOPS; + expect(getTrustedProxyHops()).toBe(1); + process.env.TRUSTED_PROXY_HOPS = "2"; + expect(getTrustedProxyHops()).toBe(2); + process.env.TRUSTED_PROXY_HOPS = "0"; + expect(getTrustedProxyHops()).toBe(0); + }); + + it("derives cookie Secure from URL or override", () => { + remember(); + delete process.env.COOKIE_SECURE; + delete process.env.PAPERCUT_PUBLIC_URL; + vi.stubEnv("NODE_ENV", "development"); + expect(getCookieSecure()).toBe(false); + + process.env.PAPERCUT_PUBLIC_URL = "https://paste.example"; + expect(getCookieSecure()).toBe(true); + + process.env.PAPERCUT_PUBLIC_URL = "http://localhost:3000"; + expect(getCookieSecure()).toBe(false); + + process.env.COOKIE_SECURE = "1"; + expect(getCookieSecure()).toBe(true); + }); }); diff --git a/server/lib/env.ts b/server/lib/env.ts index 1806502..8ae3191 100644 --- a/server/lib/env.ts +++ b/server/lib/env.ts @@ -32,3 +32,40 @@ export function getPasteAuthSecret(): string { // Deterministic dev fallback so cookies work across reloads; not for production. return "papercut-dev-only-secret-change-me"; } + +/** + * How many reverse-proxy hops to trust when reading X-Forwarded-For. + * Default 1 (single nginx/Caddy/Traefik). Set 0 to ignore XFF entirely. + */ +export function getTrustedProxyHops(): number { + const raw = process.env.TRUSTED_PROXY_HOPS; + if (raw === undefined || raw.trim() === "") return 1; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`Invalid TRUSTED_PROXY_HOPS: ${raw}`); + } + return Math.min(n, 32); +} + +/** + * Whether unlock cookies should use the Secure flag. + * Prefer explicit COOKIE_SECURE; else https PAPERCUT_PUBLIC_URL; else production NODE_ENV. + */ +export function getCookieSecure(): boolean { + const forced = process.env.COOKIE_SECURE?.trim().toLowerCase(); + if (forced === "1" || forced === "true" || forced === "yes" || forced === "on") { + return true; + } + if ( + forced === "0" || + forced === "false" || + forced === "no" || + forced === "off" + ) { + return false; + } + const pub = getPublicUrl(); + if (pub?.startsWith("https://")) return true; + if (pub?.startsWith("http://")) return false; + return process.env.NODE_ENV === "production"; +} diff --git a/server/lib/rate-limit.test.ts b/server/lib/rate-limit.test.ts index dcd03ac..90fbb54 100644 --- a/server/lib/rate-limit.test.ts +++ b/server/lib/rate-limit.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { RateLimiter, clientKeyFromRequest } from "./rate-limit"; +const prevHops = process.env.TRUSTED_PROXY_HOPS; +afterEach(() => { + if (prevHops === undefined) delete process.env.TRUSTED_PROXY_HOPS; + else process.env.TRUSTED_PROXY_HOPS = prevHops; +}); + describe("RateLimiter", () => { it("allows up to limit attempts in the window", () => { let now = 1_000_000; @@ -44,14 +50,33 @@ describe("RateLimiter", () => { }); describe("clientKeyFromRequest", () => { - it("uses first X-Forwarded-For hop when present", () => { + it("with 1 trusted hop uses the rightmost XFF entry", () => { + process.env.TRUSTED_PROXY_HOPS = "1"; const req = new Request("http://localhost/api", { headers: { "x-forwarded-for": "203.0.113.1, 10.0.0.1" }, }); + // spoofed, real-from-proxy → trust last hop + expect(clientKeyFromRequest(req)).toBe("10.0.0.1"); + }); + + it("with 2 trusted hops uses second-from-right", () => { + process.env.TRUSTED_PROXY_HOPS = "2"; + const req = new Request("http://localhost/api", { + headers: { "x-forwarded-for": "198.51.100.1, 203.0.113.1, 10.0.0.1" }, + }); expect(clientKeyFromRequest(req)).toBe("203.0.113.1"); }); + it("ignores XFF when hops is 0", () => { + process.env.TRUSTED_PROXY_HOPS = "0"; + const req = new Request("http://localhost/api", { + headers: { "x-forwarded-for": "203.0.113.1" }, + }); + expect(clientKeyFromRequest(req)).toBe("local"); + }); + it("falls back to local without XFF", () => { + delete process.env.TRUSTED_PROXY_HOPS; const req = new Request("http://localhost/api"); expect(clientKeyFromRequest(req)).toBe("local"); }); diff --git a/server/lib/rate-limit.ts b/server/lib/rate-limit.ts index 8f1bc04..332e4c4 100644 --- a/server/lib/rate-limit.ts +++ b/server/lib/rate-limit.ts @@ -3,6 +3,8 @@ * Suitable for single-node / Docker deploys. Keys are never written to logs. */ +import { getTrustedProxyHops } from "./env"; + export interface RateLimitResult { allowed: boolean; /** Seconds until the window has capacity again (when blocked) */ @@ -110,13 +112,25 @@ export function getCreateRateLimiter(): RateLimiter { /** * Coarse client key for rate limiting without logging. - * Prefer X-Forwarded-For first hop when behind a trusted proxy; else "local". + * Uses X-Forwarded-For when TRUSTED_PROXY_HOPS > 0: with N trusted proxies, + * take the Nth address from the right (the client as seen by the outer proxy). + * Keys are never written to application logs. */ export function clientKeyFromRequest(request: Request): string { + const hops = getTrustedProxyHops(); + if (hops <= 0) return "local"; + const xff = request.headers.get("x-forwarded-for"); if (xff) { - const first = xff.split(",")[0]?.trim(); - if (first) return first.slice(0, 128); + const parts = xff + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (parts.length > 0) { + const idx = Math.max(0, parts.length - hops); + const ip = parts[idx]!; + return ip.slice(0, 128); + } } // No stable remote address in the Fetch Request API without platform hooks. return "local";