feat(photos): community film strip uploads + admin moderation

Backend
- photos table + upload storage under DATA_DIR/uploads (magic-byte sniffing,
  no multipart dep, SVG rejected, wx exclusive writes)
- POST/GET /api/photos, GET /api/photos/:id/file with nosniff + sandboxed CSP
- admin routes (ADMIN_EMAILS allowlist): list, delete one, clear all
- identity-keyed rate limits (login 20/15m, signup 5/h, upload 60/h)
- cookie gains Secure when the request is https (via trustProxy)
- /api/auth/me now 200 {user:null} instead of 401 when signed out

Frontend
- landing strip section: signed-in users upload straight from the reel,
  guests get a /app?auth=1 link
- /admin page: grid of uploads with delete + clear all
- nginx: nosniff / X-Frame-Options / Referrer-Policy, forward
  X-Forwarded-Proto so the API can mark cookies Secure behind TLS

Tests: docker/backend test/security.mjs (45 checks)
This commit is contained in:
2026-09-17 22:35:12 +07:00
parent 9ba2667c6c
commit ffdefd2c9c
15 changed files with 871 additions and 18 deletions
+190 -7
View File
@@ -1,16 +1,29 @@
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { randomBytes } from 'node:crypto';
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { basename } from 'node:path';
import {
MAX_PHOTO_BYTES,
MAX_PHOTOS_PER_USER,
MAX_RECIPE_BYTES,
SESSION_COOKIE,
SESSION_MAX_AGE_S,
DUMMY_HASH,
countPhotos,
createPhoto,
createRecipe,
createSession,
createUser,
deleteAllPhotos,
deletePhoto,
deleteRecipe,
deleteSession,
findUserByEmail,
listPhotos,
listPhotosWithOwner,
listRecipes,
photoFile,
photoPath,
sessionUser,
updateRecipe,
verifyPassword,
@@ -27,7 +40,24 @@ const MIN_PASSWORD = 8;
const MAX_PASSWORD = 200;
const MAX_NAME = 120;
const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 });
// Comma-separated allowlist from the environment. An allowlist over a role
// column keeps the privilege out of the database entirely: no migration, and
// no endpoint that could ever elevate someone.
const ADMIN_EMAILS = new Set(
(process.env.ADMIN_EMAILS ?? '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
const app = Fastify({
logger: true,
bodyLimit: 1024 * 1024,
// The API is only reachable through nginx, so the forwarded headers are the
// only source of truth for the original scheme (see the Secure cookie flag).
trustProxy: true,
});
// Bodyless DELETE/logout requests still often carry Content-Type: application/json.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
@@ -40,6 +70,55 @@ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body,
}
});
// Photo uploads are the raw image bytes, not multipart: one file per request
// needs no boundary parsing, so no dependency and no parser attack surface.
app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: 'buffer' }, (_req, body, done) => {
done(null, body);
});
// ---- rate limiting --------------------------------------------------------
// Fixed window keyed on what the caller is trying to abuse — an email, or a
// user id — rather than an address: the API sits behind two proxies, so a
// request's source address is not something it can honestly trust, but the
// account being attacked cannot be rotated by the attacker.
// ponytail: in-memory, one container. Swap for @fastify/rate-limit + Redis if
// the API is ever scaled beyond that.
function limiter(max: number, windowMs: number) {
const hits = new Map<string, { n: number; until: number }>();
return (key: string): boolean => {
const t = Date.now();
if (hits.size > 5000) for (const [k, v] of hits) if (v.until <= t) hits.delete(k);
const row = hits.get(key);
if (!row || row.until <= t) {
hits.set(key, { n: 1, until: t + windowMs });
return true;
}
row.n += 1;
return row.n <= max;
};
}
const allowLogin = limiter(20, 15 * 60_000);
const allowSignup = limiter(5, 60 * 60_000);
const allowUpload = limiter(60, 60 * 60_000);
const tooMany = (reply: FastifyReply) =>
reply.header('retry-after', '900').status(429).send({ error: 'too_many_requests' });
// ---- image sniffing -------------------------------------------------------
// The declared Content-Type is a claim; the first bytes are evidence. Both must
// agree, and only these three formats are accepted — SVG in particular is never
// accepted, because it is a script container that would run on our origin.
type ImageMime = 'image/jpeg' | 'image/png' | 'image/webp';
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function sniffImage(buf: Buffer): ImageMime | null {
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return 'image/png';
if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP')
return 'image/webp';
return null;
}
const EXT: Record<ImageMime, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
// Single error shape for the whole API: { error: "..." }
app.setErrorHandler((err, req, reply) => {
const e = err as { statusCode?: number; message?: string };
@@ -61,10 +140,14 @@ function cookieOf(req: FastifyRequest, name: string): string | undefined {
return undefined;
}
function setSession(reply: FastifyReply, token: string): void {
function setSession(req: FastifyRequest, reply: FastifyReply, token: string): void {
// Secure only where the visitor actually arrived over TLS: nginx forwards the
// original scheme, so the cookie is hardened in production without breaking
// local http access to the same build.
const secure = req.protocol === 'https' ? '; Secure' : '';
reply.header(
'set-cookie',
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}`,
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}${secure}`,
);
}
@@ -111,10 +194,11 @@ app.post('/api/auth/signup', async (req, reply) => {
if (!b) return reply.status(400).send({ error: 'invalid body' });
const creds = credentials(b);
if (typeof creds === 'string') return reply.status(400).send({ error: creds });
if (!allowSignup(creds.email)) return tooMany(reply);
if (findUserByEmail(creds.email)) return reply.status(409).send({ error: 'email already registered' });
const user = createUser(creds.email, creds.password);
if (!user) return reply.status(409).send({ error: 'email already registered' });
setSession(reply, createSession(user.id));
setSession(req, reply, createSession(user.id));
return reply.status(201).send({ user });
});
@@ -123,10 +207,11 @@ app.post('/api/auth/login', async (req, reply) => {
if (!b || typeof b.email !== 'string' || typeof b.password !== 'string')
return reply.status(400).send({ error: 'invalid body' });
const email = b.email.trim().toLowerCase();
if (!allowLogin(email)) return tooMany(reply);
const row = findUserByEmail(email);
const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH);
if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' });
setSession(reply, createSession(row.id));
setSession(req, reply, createSession(row.id));
return reply.status(200).send({ user: { id: row.id, email: row.email } });
});
@@ -138,9 +223,11 @@ app.post('/api/auth/logout', async (req, reply) => {
});
app.get('/api/auth/me', async (req, reply) => {
// Signed out is an answer, not an error: "who am I?" with no session is
// nobody. A 401 here would put a console error on every anonymous visit to
// the landing page, which asks the same question to decide what to offer.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
return reply.status(200).send({ user });
return reply.status(200).send({ user: user ?? null });
});
app.get('/api/recipes', async (req, reply) => {
@@ -183,6 +270,102 @@ app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) =>
return reply.status(204).send();
});
// ---- contributed strip photos -------------------------------------------
// Anyone may read the strip; only a signed-in account may add to it. The bytes
// are written under a server-generated name, so a caller's own filename never
// reaches the filesystem, and the row is the only place the real mime lives.
app.get('/api/photos', async () => ({ photos: listPhotos() }));
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (!allowUpload(String(user.id))) return tooMany(reply);
const body = req.body;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' });
if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' });
const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
const mime = sniffImage(body);
if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' });
if (countPhotos(user.id) >= MAX_PHOTOS_PER_USER)
return reply.status(429).send({ error: 'photo quota reached' });
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
writeFileSync(photoPath(file), body, { flag: 'wx' });
const photo = createPhoto(user.id, file, mime, body.length);
return reply.status(201).send({ photo });
});
app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
const row = photoFile(id);
// The column is server-generated, but re-check it on the way out: a single
// path segment is the only thing that can ever be opened.
if (!row || basename(row.file) !== row.file) return reply.status(404).send({ error: 'not_found' });
let data: Buffer;
try {
data = readFileSync(photoPath(row.file));
} catch {
return reply.status(404).send({ error: 'not_found' });
}
return reply
.header('content-type', row.mime)
.header('x-content-type-options', 'nosniff')
// Belt and braces on top of the mime allowlist: even a hostile still cannot
// act as a document on this origin.
.header('content-security-policy', "default-src 'none'; sandbox")
// Short, not immutable: an admin deleting a contribution has to be able to
// take it off the web, and a cached copy would outlive the removal.
.header('cache-control', 'public, max-age=60')
.send(data);
});
// ---- admin ---------------------------------------------------------------
// Moderation only: the allowlist can list everything and clean up. There is
// deliberately no endpoint here that grants the privilege itself.
function admin(req: FastifyRequest): User | { status: number } {
const user = auth(req);
if (!user) return { status: 401 };
if (!isAdmin(user)) return { status: 403 };
return user;
}
function unlink(file: string): void {
try {
unlinkSync(photoPath(file));
} catch {
// Already gone; the row is what matters.
}
}
app.get('/api/admin/photos', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
return reply.status(200).send({ photos: listPhotosWithOwner() });
});
app.delete<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const file = deletePhoto(id);
if (!file) return reply.status(404).send({ error: 'photo not found' });
unlink(file);
return reply.status(204).send();
});
app.delete('/api/admin/photos', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
const files = deleteAllPhotos();
for (const file of files) unlink(file);
return reply.status(200).send({ removed: files.length });
});
app
.listen({ port: PORT, host: HOST })
.catch((err) => {