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 <noreply@anthropic.com>
This commit is contained in:
parent
7c375d70b8
commit
ad7f3cb666
@ -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/<sha256>.<ext>`, 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.
|
**File store.** Uploaded files are kept in `DIVAN_FILES_DIR` (default `../divan-files` beside the repo) under their SHA-256, `ab/cd/<sha256>.<ext>`, 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)
|
## 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.
|
`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.
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { test, after } from 'node:test';
|
import { test, after } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import Fastify from 'fastify';
|
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';
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
after(() => pool.end());
|
after(() => pool.end());
|
||||||
@ -27,6 +27,22 @@ test('rate limiter: max per window, then resets', () => {
|
|||||||
assert.equal(allow('ip', 1001), true);
|
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<string, string>) => ({ 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 () => {
|
test('HTTP flow: sign up, sign in, wrong password, change password, delete', async () => {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
authRoutes(app);
|
authRoutes(app);
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
// Accounts: email address and password only (no email is sent; owner decision 2026-10-08).
|
// 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
|
// 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.
|
// 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/signup {email, password} -> {token, user}
|
||||||
// POST /api/auth/signin {email, password} -> {token, user}
|
// POST /api/auth/signin {email, password} -> {token, user}
|
||||||
// GET /api/auth/me Bearer token -> {user}
|
// GET /api/auth/me Bearer token -> {user}
|
||||||
@ -85,7 +88,14 @@ export async function sessionUser(req: FastifyRequest) {
|
|||||||
return u ?? null;
|
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) {
|
export function authRoutes(app: FastifyInstance) {
|
||||||
app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signup', async (req, reply) => {
|
app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signup', async (req, reply) => {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export async function auth(path: string, opts: { token?: string; body?: object;
|
|||||||
...(opts.body && { 'content-type': 'application/json' }),
|
...(opts.body && { 'content-type': 'application/json' }),
|
||||||
...(opts.token && { authorization: `Bearer ${opts.token}` }),
|
...(opts.token && { authorization: `Bearer ${opts.token}` }),
|
||||||
...(opts.ip && { 'x-client-ip': opts.ip }),
|
...(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,
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user