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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |

Expand Down
6 changes: 4 additions & 2 deletions server/lib/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
5 changes: 3 additions & 2 deletions server/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
28 changes: 28 additions & 0 deletions server/lib/rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
44 changes: 38 additions & 6 deletions server/lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,36 @@ 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<string, number[]>();
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;
}

/**
* Record an attempt and return whether it is allowed.
* 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) ?? [];
Expand All @@ -57,6 +70,7 @@ export class RateLimiter {

recent.push(now);
this.hits.set(key, recent);
this.evictIfOverCap();
return {
allowed: true,
retryAfterSec: 0,
Expand All @@ -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);
}
}
}
Expand Down
Loading