From ad7f3cb666168f74b92b2d591155e54898cd3427 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 17:38:55 +0200 Subject: [PATCH] Rate limits can't be dodged with a made-up x-client-ip: the API believes it only from the site (DIVAN_SITE_KEY in x-site-key, timing-safe; this machine only when no key is set) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ api/src/auth.test.ts | 18 +++++++++++++++++- api/src/auth.ts | 14 ++++++++++++-- web/src/lib/auth.ts | 1 + 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 056b1113..b51e1292 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ The import upserts, so re-running it after a divan-data sync applies the changes **File store.** Uploaded files are kept in `DIVAN_FILES_DIR` (default `../divan-files` beside the repo) under their SHA-256, `ab/cd/.`, so a file uploaded twice is stored once and the folder spreads evenly. On the server, point it at storage that can grow: an attached block-storage volume (e.g. Vultr Block Storage, resizable later) mounted at `/srv/divan-files`, or an S3-compatible bucket mounted with rclone. Back it up with the database. The database keeps the details, indexed by poet and by a trigram index on the title (and the text of text books), so lists and search stay fast. +**Readers' addresses and rate limits.** Sign-in and sign-up are limited per address. The site passes the reader's address to the API in `x-client-ip`, which the API believes only from the site: set the same random `DIVAN_SITE_KEY` for both (e.g. `openssl rand -hex 32`); without it, only requests from the same machine count as the site. Keep the API off the internet (only the site talks to it), and let Caddy be the only way in: it replaces any `X-Forwarded-For` a visitor sends with their real address, which is what the site reads. + ## Daily content sync (server) `deploy/sync.sh` keeps a server current: it updates a divan-data checkout, fetches new and edited works from Wikisource (incremental, about a minute), rebuilds the export and upserts it into PostgreSQL. The site shows new content immediately. Runs are locked so they never overlap. diff --git a/api/src/auth.test.ts b/api/src/auth.test.ts index b95fb9d5..b3a37dfe 100644 --- a/api/src/auth.test.ts +++ b/api/src/auth.test.ts @@ -1,7 +1,7 @@ import { test, after } from 'node:test'; import assert from 'node:assert/strict'; import Fastify from 'fastify'; -import { hashPassword, verifyPassword, normaliseEmail, validEmail, passwordProblem, limiter, authRoutes } from './auth.ts'; +import { hashPassword, verifyPassword, normaliseEmail, validEmail, passwordProblem, limiter, authRoutes, ip } from './auth.ts'; import { pool } from './db.ts'; after(() => pool.end()); @@ -27,6 +27,22 @@ test('rate limiter: max per window, then resets', () => { assert.equal(allow('ip', 1001), true); }); +test('client address: x-client-ip is trusted only from the site, so a made-up one cannot dodge the limits', () => { + const req = (from: string, headers: Record) => ({ ip: from, headers }) as any; + const saved = process.env.DIVAN_SITE_KEY; + try { + delete process.env.DIVAN_SITE_KEY; // local development: only this machine may pass an address + assert.equal(ip(req('127.0.0.1', { 'x-client-ip': '5.5.5.5' })), '5.5.5.5'); + assert.equal(ip(req('203.0.113.9', { 'x-client-ip': '5.5.5.5' })), '203.0.113.9', 'a stranger is limited by their own address'); + process.env.DIVAN_SITE_KEY = 'site-secret-1234'; + assert.equal(ip(req('10.0.0.3', { 'x-client-ip': '5.5.5.5', 'x-site-key': 'site-secret-1234' })), '5.5.5.5', 'the site, with the key'); + assert.equal(ip(req('10.0.0.3', { 'x-client-ip': '5.5.5.5', 'x-site-key': 'wrong' })), '10.0.0.3'); + assert.equal(ip(req('127.0.0.1', { 'x-client-ip': '5.5.5.5' })), '127.0.0.1', 'with a key set, even this machine needs it'); + } finally { + if (saved === undefined) delete process.env.DIVAN_SITE_KEY; else process.env.DIVAN_SITE_KEY = saved; + } +}); + test('HTTP flow: sign up, sign in, wrong password, change password, delete', async () => { const app = Fastify(); authRoutes(app); diff --git a/api/src/auth.ts b/api/src/auth.ts index e3763df2..8587688d 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -1,7 +1,10 @@ // Accounts: email address and password only (no email is sent; owner decision 2026-10-08). // Passwords: scrypt with a per-user salt. Sessions: a random token held by the site in an HTTP-only // cookie; the database stores only its SHA-256, so a database leak does not give working sessions. -// The API is private (only the site calls it), so the site passes the reader's IP in x-client-ip. +// Rate limits go by the reader's address. The site passes it in x-client-ip, and only the site may: it proves itself +// with DIVAN_SITE_KEY in x-site-key (set the same key for the API and the site in production); with no key set +// (local development) only requests from this machine may. Anyone else is limited by their own address, so a +// made-up x-client-ip cannot get round the limits. // POST /api/auth/signup {email, password} -> {token, user} // POST /api/auth/signin {email, password} -> {token, user} // GET /api/auth/me Bearer token -> {user} @@ -85,7 +88,14 @@ export async function sessionUser(req: FastifyRequest) { return u ?? null; } -const ip = (req: FastifyRequest) => (req.headers['x-client-ip'] as string) || req.ip; +const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1']; +const fromSite = (req: FastifyRequest) => { + const key = process.env.DIVAN_SITE_KEY; + if (!key) return LOOPBACK.includes(req.ip); + const given = Buffer.from(String(req.headers['x-site-key'] ?? '')), want = Buffer.from(key); + return given.length === want.length && timingSafeEqual(given, want); +}; +export const ip = (req: FastifyRequest) => (fromSite(req) && (req.headers['x-client-ip'] as string)) || req.ip; export function authRoutes(app: FastifyInstance) { app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signup', async (req, reply) => { diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 8c47667d..25d392c6 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -13,6 +13,7 @@ export async function auth(path: string, opts: { token?: string; body?: object; ...(opts.body && { 'content-type': 'application/json' }), ...(opts.token && { authorization: `Bearer ${opts.token}` }), ...(opts.ip && { 'x-client-ip': opts.ip }), + ...(process.env.DIVAN_SITE_KEY && { 'x-site-key': process.env.DIVAN_SITE_KEY }), // lets the API trust x-client-ip }, body: opts.body ? JSON.stringify(opts.body) : undefined, });