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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>` or sidebar)
- Proxy-aware runtime: `TRUSTED_PROXY_HOPS` for rate-limit client keys; Secure cookies from `PAPERCUT_PUBLIC_URL` / `COOKIE_SECURE`

### Fixed

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions server/lib/auth-cookie.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
};
Expand Down
37 changes: 36 additions & 1 deletion server/lib/env.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<(typeof keys)[number], string | undefined>> = {};
Expand Down Expand Up @@ -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);
});
});
37 changes: 37 additions & 0 deletions server/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
29 changes: 27 additions & 2 deletions server/lib/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
});
Expand Down
20 changes: 17 additions & 3 deletions server/lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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";
Expand Down
Loading