6bbf77860b
- an account can carry a picture: POST /api/auth/avatar (raw bytes, sniffed, replaces and unlinks the old file) and the public GET /api/users/:id/avatar. It rides wherever the account is named — the landing chip, the studio TopBar, the profile form. - new /profile page for members, sharing one Profile form (picture, email, password) with the admin drawer. - /admin is now one bordered frame whose left column is Profile / User account / Pictures / Close. Pictures lists every photo in the system with the slot that shows it; User account lists each account's name, email, picture and contribution count. - account control opens a menu: Admin page + Log out for an admin, Profile + Log out for a member.
364 lines
19 KiB
JavaScript
364 lines
19 KiB
JavaScript
// 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(4 * 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));
|
||
|
||
// ---- 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);
|