Files
RecipesCam/docker/backend/test/security.mjs
T
3dtours 43d86b4b6f web: moderate accounts, accept 12MB uploads, put SAVE under CREATE
- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE.
  Blocked = cannot sign in (sessions swept), removed = hidden from the strip
  and cannot sign in, both reversible; DELETE drops the account with its
  photos and recipes and unlinks the files. An allowlisted account is never
  a target, so an admin cannot moderate or delete itself.
- Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and
  the browser shrinks an oversized still before sending it (2048px JPEG,
  avatars 512px) so the declared type still matches the sniffed bytes.
- The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab,
  labelled SAVE RECIPES.
2026-09-18 10:33:35 +07:00

415 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Security self-check for the API. Boots the real server against a throwaway
// DATA_DIR and exercises the boundaries that matter: who may write, what may be
// written, who may read a photo back, and who may moderate the strip.
//
// npm test (from docker/backend/)
//
// Node only — no test framework, no network beyond loopback.
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PORT = Number(process.env.TEST_PORT || 3411);
const BASE = `http://127.0.0.1:${PORT}/api`;
const ADMIN_EMAIL = 'admin@test.local';
const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-sec-'));
// A 1x1 PNG, and the first bytes of a JPEG (all the sniffer looks at).
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const JPEG_HEAD = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64)]);
let pass = 0;
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(`PASS ${name}`);
} else {
fail++;
console.log(`FAIL ${name}${detail ? ` :: ${detail}` : ''}`);
}
};
// One cookie jar per actor, so "signed in as A" cannot leak into B.
function actor() {
let cookie = '';
return {
get cookie() {
return cookie;
},
async req(path, init = {}) {
const headers = { ...(init.headers ?? {}) };
if (cookie) headers.cookie = cookie;
const res = await fetch(BASE + path, { ...init, headers });
const set = res.headers.getSetCookie?.() ?? (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
for (const line of set) {
const value = line.split(';')[0];
if (value.startsWith('rc_session=')) cookie = value.endsWith('=') ? '' : value;
}
const text = await res.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: res.status, headers: res.headers, setCookie: set.join(' | '), body };
},
signup(email, password = 'supersecret1') {
return this.req('/auth/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
},
upload(bytes, type) {
return this.req('/photos', { method: 'POST', headers: { 'content-type': type }, body: bytes });
},
avatar(bytes, type) {
return this.req('/auth/avatar', { method: 'POST', headers: { 'content-type': type }, body: bytes });
},
};
}
// Run the sources, not a possibly stale build: the point of this suite is to
// test the code as written.
const tsx = join(ROOT, 'node_modules/.bin/tsx');
const entry = existsSync(tsx) ? [tsx, 'src/server.ts'] : ['dist/server.js'];
const server = spawn(process.execPath, entry, {
cwd: ROOT,
env: { ...process.env, PORT: String(PORT), DATA_DIR, ADMIN_EMAILS: ADMIN_EMAIL, NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLog = '';
server.stdout.on('data', (d) => (serverLog += d));
server.stderr.on('data', (d) => (serverLog += d));
async function waitForServer() {
for (let i = 0; i < 100; i++) {
try {
const res = await fetch(`${BASE}/health`);
if (res.ok) return true;
} catch {
/* not up yet */
}
await new Promise((r) => setTimeout(r, 100));
}
return false;
}
try {
if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`);
const stamp = Date.now();
const admin = actor();
const user = actor();
const other = actor();
// ---- accounts -----------------------------------------------------------
check('health responds', (await fetch(`${BASE}/health`)).ok);
const badEmail = await user.signup('not-an-email');
check('signup rejects a malformed email', badEmail.status === 400, `got ${badEmail.status}`);
const badPw = await user.signup(`short${stamp}@test.local`, 'short');
check('signup rejects a short password', badPw.status === 400, `got ${badPw.status}`);
const adminSignup = await admin.signup(ADMIN_EMAIL);
check('admin account signs up', adminSignup.status === 201, `got ${adminSignup.status}`);
const userSignup = await user.signup(`contributor${stamp}@test.local`);
check('contributor account signs up', userSignup.status === 201, `got ${userSignup.status}`);
await other.signup(`other${stamp}@test.local`);
const cookie = userSignup.setCookie;
check('session cookie is HttpOnly', /HttpOnly/i.test(cookie), cookie);
check('session cookie is SameSite', /SameSite=Lax/i.test(cookie), cookie);
check('session cookie is not Secure over plain http', !/;\s*Secure/i.test(cookie), cookie);
check('session token is 256 bits of hex', /rc_session=[0-9a-f]{64}/.test(cookie), cookie);
// The proxy hands nginx the public scheme; a TLS visitor must get Secure.
const tls = actor();
const tlsSignup = await tls.req('/auth/signup', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' },
body: JSON.stringify({ email: `tls${stamp}@test.local`, password: 'supersecret1' }),
});
check('session cookie is Secure behind https', /;\s*Secure/i.test(tlsSignup.setCookie), tlsSignup.setCookie);
// Documented, not fixed: the 409 is a deliberate UX choice and doubles as an
// account-existence oracle.
const dup = await actor().signup(ADMIN_EMAIL);
check('duplicate signup is a 409 (known user-enumeration oracle)', dup.status === 409, `got ${dup.status}`);
const noUser = await actor().req('/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: `ghost${stamp}@test.local`, password: 'supersecret1' }),
});
check('unknown email and wrong password look identical', noUser.status === 401, `got ${noUser.status}`);
const anonMe = await actor().req('/auth/me');
check('signed out is a 200 with no user', anonMe.status === 200 && anonMe.body?.user === null, JSON.stringify(anonMe.body));
const ownMe = await user.req('/auth/me');
check('/auth/me reports the signed-in account', ownMe.body?.user?.email === `contributor${stamp}@test.local`, JSON.stringify(ownMe.body));
// ---- rate limiting ------------------------------------------------------
const brute = actor();
const bruteEmail = `brute${stamp}@test.local`;
let limited = 0;
for (let i = 0; i < 21; i++) {
const res = await brute.req('/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: bruteEmail, password: 'wrong-password' }),
});
if (res.status === 429) limited++;
}
check('login attempts are rate limited', limited === 1, `429s: ${limited}`);
// ---- upload boundary ----------------------------------------------------
check('guest cannot upload', (await actor().upload(PNG, 'image/png')).status === 401);
check(
'non-image content-type is refused',
(await user.upload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'), 'image/svg+xml')).status === 415,
);
check(
'svg bytes under an image content-type is refused',
(await user.upload(Buffer.from('<svg onload="alert(1)"/>'), 'image/jpeg')).status === 415,
);
check(
'a declared type that disagrees with the bytes is refused',
(await user.upload(JPEG_HEAD, 'image/png')).status === 415,
);
check('empty body is refused', (await user.upload(Buffer.alloc(0), 'image/png')).status === 400);
const tooBig = Buffer.concat([PNG, Buffer.alloc(13 * 1024 * 1024)]);
check('an over-limit body is refused', (await user.upload(tooBig, 'image/png')).status === 413);
const created = await user.upload(PNG, 'image/png');
check('a signed-in member can upload a real PNG', created.status === 201, JSON.stringify(created.body));
// ---- what the public may read ------------------------------------------
const list = await actor().req('/photos');
const listed = list.body?.photos ?? [];
check('the strip is readable anonymously', list.status === 200);
check(
'the public strip leaks no owner',
listed.length > 0 && !('userId' in listed[0]) && !('email' in listed[0]) && !('file' in listed[0]),
JSON.stringify(listed[0]),
);
const id = created.body?.photo?.id;
const served = await fetch(`${BASE}/photos/${id}/file`);
check('a contributed photo is served', served.status === 200);
check('the served photo keeps its sniffed type', served.headers.get('content-type') === 'image/png');
check('the served photo is nosniff', served.headers.get('x-content-type-options') === 'nosniff');
check('the served photo is sandboxed', (served.headers.get('content-security-policy') ?? '').includes('sandbox'));
check('the bytes round-trip intact', Buffer.from(await served.arrayBuffer()).equals(PNG));
check('an unknown id is a 404', (await fetch(`${BASE}/photos/999999/file`)).status === 404);
check('a non-numeric id is a 404', (await fetch(`${BASE}/photos/abc/file`)).status === 404);
check(
'an id cannot escape the uploads directory',
(await fetch(`${BASE}/photos/..%2f..%2fetc%2fpasswd/file`)).status === 404,
);
// ---- moderation ---------------------------------------------------------
check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401);
const forbidden = await user.req('/admin/photos');
check('a plain member is 403, not 200', forbidden.status === 403, `got ${forbidden.status}`);
const adminList = await admin.req('/admin/photos');
const rows = adminList.body?.photos ?? [];
check('an admin lists contributions', adminList.status === 200 && rows.length > 0);
check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? '')));
check('a fresh upload lands in the strip slot', rows.find((r) => r.id === id)?.slot === 'strip');
check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403);
// ---- placement ----------------------------------------------------------
const patch = (path, body) =>
admin.req(path, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
check(
'a plain member cannot place a photo',
(await user.req(`/admin/photos/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slot: 'qr' }),
})).status === 403,
);
const placed = await patch(`/admin/photos/${id}`, { slot: 'qr' });
check('an admin moves a photo to a live slot', placed.status === 200 && placed.body?.slot === 'qr', JSON.stringify(placed.body));
check(
'the slot is public, the owner is not',
((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'qr',
);
check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slot: 'nope' })).status === 400);
check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slot: 'qr' })).status === 404);
const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' });
check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`);
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404);
check('the deleted row is gone from the listing', !((await admin.req('/admin/photos')).body?.photos ?? []).some((r) => r.id === id));
// ---- quota --------------------------------------------------------------
const quota = actor();
await quota.signup(`quota${stamp}@test.local`);
let last = 0;
for (let i = 0; i < 13; i++) last = (await quota.upload(PNG, 'image/png')).status;
check('uploads are capped per account', last === 429, `13th upload: ${last}`);
// The cap is a member fair-use rule; the curator stocks the landing page from
// one account, so it must not apply to the allowlist.
let adminLast = 0;
for (let i = 0; i < 13; i++) adminLast = (await admin.upload(PNG, 'image/png')).status;
check('the admin is exempt from the member quota', adminLast === 201, `13th admin upload: ${adminLast}`);
const cleared = await admin.req('/admin/photos', { method: 'DELETE' });
check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body));
check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0);
// ---- profile: an account edits itself ----------------------------------
const JSON_HDR = { 'content-type': 'application/json' };
const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
const member = actor();
await member.signup(`profile${stamp}@test.local`);
const anonEdit = await edit(actor(), { password: 'another-secret-1', currentPassword: 'supersecret1' });
check('a profile edit needs a session', anonEdit.status === 401, `got ${anonEdit.status}`);
const badCurrent = await edit(member, { password: 'another-secret-1', currentPassword: 'not-the-password' });
check('a profile edit needs the current password', badCurrent.status === 403, `got ${badCurrent.status}`);
const takenEmail = await edit(member, { email: ADMIN_EMAIL, currentPassword: 'supersecret1' });
check('a profile edit refuses a taken email', takenEmail.status === 409, `got ${takenEmail.status}`);
const shortNew = await edit(member, { password: 'short', currentPassword: 'supersecret1' });
check('a profile edit refuses a short password', shortNew.status === 400, `got ${shortNew.status}`);
const newEmail = `renamed${stamp}@test.local`;
const renamed = await edit(member, { email: newEmail, currentPassword: 'supersecret1' });
check('an admin-visible profile edit changes the email', renamed.status === 200 && renamed.body?.user?.email === newEmail, JSON.stringify(renamed.body));
const login = (email, password) =>
actor().req('/auth/login', { method: 'POST', headers: JSON_HDR, body: JSON.stringify({ email, password }) });
check('the account logs in under the new email', (await login(newEmail, 'supersecret1')).status === 200);
check('the old email no longer logs in', (await login(`profile${stamp}@test.local`, 'supersecret1')).status === 401);
// The session that made the edit is the same row, so it also changes the password.
const newPassword = 'second-secret-1';
const rekeyed = await edit(member, { password: newPassword, currentPassword: 'supersecret1' });
check('an account changes its own password', rekeyed.status === 200, `got ${rekeyed.status}`);
check('the old password stops working', (await login(newEmail, 'supersecret1')).status === 401);
check('the new password works', (await login(newEmail, newPassword)).status === 200);
// ---- admin: the account list -------------------------------------------
const anonUsers = await actor().req('/admin/users');
check('the user list is not public', anonUsers.status === 401, `got ${anonUsers.status}`);
const memberUsers = await member.req('/admin/users');
check('a member cannot read the user list', memberUsers.status === 403, `got ${memberUsers.status}`);
const adminUsers = await admin.req('/admin/users');
const adminRow = (adminUsers.body?.users ?? []).find((u) => u.email === ADMIN_EMAIL);
check('an admin reads the user list', adminUsers.status === 200 && Array.isArray(adminUsers.body?.users), `got ${adminUsers.status}`);
check('the list flags the allowlisted account', adminRow?.admin === true, JSON.stringify(adminRow));
check('the list counts each account’s photos', typeof adminRow?.photos === 'number', JSON.stringify(adminRow));
// ---- avatar: the picture beside the name -------------------------------
const memberId = renamed.body?.user?.id;
const noSession = await actor().avatar(PNG, 'image/png');
check('an avatar upload needs a session', noSession.status === 401, `got ${noSession.status}`);
const badAvatar = await member.avatar(Buffer.from('<svg onload="alert(1)"/>'), 'image/png');
check('an avatar upload sniffs the bytes', badAvatar.status === 415, `got ${badAvatar.status}`);
const gaveAvatar = await member.avatar(PNG, 'image/png');
const avatarUrl = gaveAvatar.body?.user?.avatar;
check('a member uploads an avatar', gaveAvatar.status === 200 && typeof avatarUrl === 'string', JSON.stringify(gaveAvatar.body));
check('the avatar URL points at the account', new RegExp(`^/api/users/${memberId}/avatar\\?v=[0-9a-f]{32}$`).test(String(avatarUrl)), String(avatarUrl));
const servedAvatar = await fetch(`http://127.0.0.1:${PORT}${avatarUrl}`);
check('an avatar is served without a session', servedAvatar.status === 200, `got ${servedAvatar.status}`);
check('an avatar carries its image type', servedAvatar.headers.get('content-type') === 'image/png', String(servedAvatar.headers.get('content-type')));
check('an avatar is cacheable for a long time', (servedAvatar.headers.get('cache-control') ?? '').includes('immutable'), String(servedAvatar.headers.get('cache-control')));
check('the avatar bytes round-trip intact', Buffer.from(await servedAvatar.arrayBuffer()).equals(PNG));
const replaced = await member.avatar(JPEG_HEAD, 'image/jpeg');
const replacedUrl = replaced.body?.user?.avatar;
check('a second avatar replaces the first', replaced.status === 200 && replacedUrl !== avatarUrl, JSON.stringify(replaced.body));
check('the replaced avatar file is gone', !existsSync(join(DATA_DIR, 'avatars', `${String(avatarUrl).split('?v=')[1]}.png`)));
check('the new avatar resolves', (await fetch(`http://127.0.0.1:${PORT}${replacedUrl}`)).status === 200);
const ghost = await actor().req('/users/999999/avatar');
check('an unknown account has no avatar', ghost.status === 404, `got ${ghost.status}`);
// The moderation list has to show the same face beside the email.
const listedUsers = (await admin.req('/admin/users')).body?.users ?? [];
const memberRow = listedUsers.find((u) => u.id === memberId);
check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow));
// ---- moderation: block, remove, delete an account -----------------------
const target = actor();
await target.signup(`moderated${stamp}@test.local`);
const targetId = (await target.req('/auth/me')).body?.user?.id;
const targetPhoto = (await target.upload(PNG, 'image/png')).body?.photo;
const targetPhotoUrl = `http://127.0.0.1:${PORT}/api/photos/${targetPhoto?.id}/file`;
const moderate = (a, id, body) => a.req(`/admin/users/${id}`, { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
check('a fresh account is moderatable', Number.isInteger(targetId) && Number.isInteger(targetPhoto?.id), `${targetId}/${targetPhoto?.id}`);
const anonModerate = await moderate(actor(), targetId, { blocked: true });
check('moderating needs a session', anonModerate.status === 401, `got ${anonModerate.status}`);
const memberModerate = await moderate(member, targetId, { blocked: true });
check('a member cannot moderate', memberModerate.status === 403, `got ${memberModerate.status}`);
const selfModerate = await moderate(admin, adminRow?.id, { blocked: true });
check('an allowlisted account cannot be moderated', selfModerate.status === 403, `got ${selfModerate.status}`);
const ghostModerate = await moderate(admin, 999999, { blocked: true });
check('an unknown account is a 404', ghostModerate.status === 404, `got ${ghostModerate.status}`);
const badModerate = await moderate(admin, targetId, { blocked: 'yes' });
check('a moderation body is type-checked', badModerate.status === 400, `got ${badModerate.status}`);
const blocked = await moderate(admin, targetId, { blocked: true });
check('an admin blocks an account', blocked.status === 200 && blocked.body?.user?.blocked === true, JSON.stringify(blocked.body));
const blockedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
check('a blocked account cannot sign in', blockedLogin.status === 403 && blockedLogin.body?.error === 'account blocked', JSON.stringify(blockedLogin.body));
check('a blocked session stops being a user', (await target.req('/auth/me')).body?.user === null, JSON.stringify((await target.req('/auth/me')).body));
const blockedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
check('the list flags a blocked account', blockedRow?.blocked === true, JSON.stringify(blockedRow));
const unblocked = await moderate(admin, targetId, { blocked: false });
check('an admin unblocks an account', unblocked.status === 200 && unblocked.body?.user?.blocked === false, JSON.stringify(unblocked.body));
check('an unblocked account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
check('its photo is back on the strip', ((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
const removed = await moderate(admin, targetId, { removed: true });
check('an admin removes an account', removed.status === 200 && removed.body?.user?.removed === true, JSON.stringify(removed.body));
const removedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
check('a removed account cannot sign in', removedLogin.status === 403 && removedLogin.body?.error === 'account removed', JSON.stringify(removedLogin.body));
check('a removed account’s photos leave the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
const removedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
check('the list flags a removed account', removedRow?.removed === true, JSON.stringify(removedRow));
const restored = await moderate(admin, targetId, { removed: false });
check('an admin restores an account', restored.status === 200 && restored.body?.user?.removed === false, JSON.stringify(restored.body));
check('a restored account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
const hardDelete = await admin.req(`/admin/users/${targetId}`, { method: 'DELETE' });
check('an admin deletes an account outright', hardDelete.status === 204, `got ${hardDelete.status}`);
check('a deleted account leaves the list', !((await admin.req('/admin/users')).body?.users ?? []).some((u) => u.id === targetId));
check('a deleted account cannot sign in', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 401);
check('a deleted account’s photo file is unlinked', (await fetch(targetPhotoUrl)).status === 404);
// ---- pre-existing guarantees still hold ---------------------------------
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
const missing = await actor().req('/nope');
check('unknown routes keep the single error shape', missing.status === 404 && missing.body?.error === 'not_found');
} catch (err) {
fail++;
console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`);
console.log(serverLog.slice(-2000));
} finally {
server.kill('SIGTERM');
rmSync(DATA_DIR, { recursive: true, force: true });
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);