ffdefd2c9c
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)
375 lines
15 KiB
TypeScript
375 lines
15 KiB
TypeScript
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,
|
|
type Recipe,
|
|
type User,
|
|
} from './db';
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const HOST = '0.0.0.0';
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const MAX_EMAIL = 254;
|
|
const MIN_PASSWORD = 8;
|
|
const MAX_PASSWORD = 200;
|
|
const MAX_NAME = 120;
|
|
|
|
// 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) => {
|
|
const raw = (body as string).trim();
|
|
if (raw === '') return done(null, undefined);
|
|
try {
|
|
done(null, JSON.parse(raw));
|
|
} catch {
|
|
done(Object.assign(new Error('invalid JSON body'), { statusCode: 400 }));
|
|
}
|
|
});
|
|
|
|
// 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 };
|
|
const status = e.statusCode && e.statusCode >= 400 ? e.statusCode : 500;
|
|
if (status >= 500) req.log.error(err);
|
|
reply.status(status).send({ error: status >= 500 ? 'internal_error' : (e.message ?? 'error') });
|
|
});
|
|
app.setNotFoundHandler((_req, reply) => reply.status(404).send({ error: 'not_found' }));
|
|
|
|
// ---- cookie helpers (hand-rolled: only one cookie, no plugin needed) ----
|
|
function cookieOf(req: FastifyRequest, name: string): string | undefined {
|
|
const raw = req.headers.cookie;
|
|
if (!raw) return undefined;
|
|
for (const part of raw.split(';')) {
|
|
const eq = part.indexOf('=');
|
|
if (eq === -1) continue;
|
|
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
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}${secure}`,
|
|
);
|
|
}
|
|
|
|
function clearSession(reply: FastifyReply): void {
|
|
reply.header('set-cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
|
}
|
|
|
|
// ---- validation at the trust boundary ----
|
|
type Json = Record<string, unknown>;
|
|
|
|
function bodyOf(req: FastifyRequest): Json | undefined {
|
|
const b = req.body as unknown;
|
|
return b !== null && typeof b === 'object' && !Array.isArray(b) ? (b as Json) : undefined;
|
|
}
|
|
|
|
function credentials(b: Json): { email: string; password: string } | string {
|
|
const email = typeof b.email === 'string' ? b.email.trim().toLowerCase() : '';
|
|
const password = typeof b.password === 'string' ? b.password : '';
|
|
if (!email || email.length > MAX_EMAIL || !EMAIL_RE.test(email)) return 'invalid email';
|
|
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD)
|
|
return `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters`;
|
|
return { email, password };
|
|
}
|
|
|
|
function recipePayload(b: Json): { name: string; recipe: Json } | string {
|
|
const name = typeof b.name === 'string' ? b.name.trim() : '';
|
|
const recipe = b.recipe;
|
|
if (!name || name.length > MAX_NAME) return `name must be 1-${MAX_NAME} characters`;
|
|
if (recipe === null || typeof recipe !== 'object' || Array.isArray(recipe)) return 'recipe must be an object';
|
|
if (Buffer.byteLength(JSON.stringify(recipe)) > MAX_RECIPE_BYTES) return 'recipe too large';
|
|
return { name, recipe: recipe as Json };
|
|
}
|
|
|
|
function auth(req: FastifyRequest): User | undefined {
|
|
const token = cookieOf(req, SESSION_COOKIE);
|
|
return token ? sessionUser(token) : undefined;
|
|
}
|
|
|
|
// ---- routes ----
|
|
app.get('/api/health', async () => ({ ok: true }));
|
|
|
|
app.post('/api/auth/signup', async (req, reply) => {
|
|
const b = bodyOf(req);
|
|
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(req, reply, createSession(user.id));
|
|
return reply.status(201).send({ user });
|
|
});
|
|
|
|
app.post('/api/auth/login', async (req, reply) => {
|
|
const b = bodyOf(req);
|
|
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(req, reply, createSession(row.id));
|
|
return reply.status(200).send({ user: { id: row.id, email: row.email } });
|
|
});
|
|
|
|
app.post('/api/auth/logout', async (req, reply) => {
|
|
const token = cookieOf(req, SESSION_COOKIE);
|
|
if (token) deleteSession(token);
|
|
clearSession(reply);
|
|
return reply.status(204).send();
|
|
});
|
|
|
|
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);
|
|
return reply.status(200).send({ user: user ?? null });
|
|
});
|
|
|
|
app.get('/api/recipes', async (req, reply) => {
|
|
const user = auth(req);
|
|
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
|
return reply.status(200).send({ recipes: listRecipes(user.id) });
|
|
});
|
|
|
|
app.post('/api/recipes', async (req, reply) => {
|
|
const user = auth(req);
|
|
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
|
const b = bodyOf(req);
|
|
const payload = b && recipePayload(b);
|
|
if (typeof payload === 'string' || !payload)
|
|
return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' });
|
|
const recipe: Recipe = createRecipe(user.id, payload.name, payload.recipe);
|
|
return reply.status(201).send({ recipe });
|
|
});
|
|
|
|
app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
|
|
const user = auth(req);
|
|
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
|
|
const b = bodyOf(req);
|
|
const payload = b && recipePayload(b);
|
|
if (typeof payload === 'string' || !payload)
|
|
return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' });
|
|
const recipe = updateRecipe(user.id, id, payload.name, payload.recipe);
|
|
if (!recipe) return reply.status(404).send({ error: 'recipe not found' });
|
|
return reply.status(200).send({ recipe });
|
|
});
|
|
|
|
app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
|
|
const user = auth(req);
|
|
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
|
|
if (!deleteRecipe(user.id, id)) return reply.status(404).send({ error: 'recipe not found' });
|
|
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) => {
|
|
app.log.error(err);
|
|
process.exit(1);
|
|
});
|