fix(security,reliability): address code-review findings
HIGH
- async handlers: Express 4 does not catch rejected promises and Node 22
exits on unhandled rejections — a malformed body ({} to /cropper/dequeue
etc.) could hang a request or kill the process. Every cropper route is
now wrapped (throw → 500 via next), request bodies are validated up
front (400), and JSON.parse of queue items is guarded. Server-wide
error middleware added, honouring err.status (413 for oversized bodies).
- error pages HTML-escape all interpolated values — the login failure page
rendered upstream (portal) error text raw.
MEDIUM
- outbound calls carry AbortSignal timeouts (5s OpenFGA verify, 15s
cropper upstreams) — a hung upstream can no longer pin a request.
- body limits scoped: /cropper gets 50mb (picture data URLs), everything
else 1mb (previously 50mb globally, memory-DoS surface pre-auth).
- cropper save validates the client-supplied domain against the catalog;
picture fetches validate the Minio object key (id-domains/ prefix, no ..).
LOW
- static assets served before the session middleware (no PocketBase load
per asset); landing header builds DOM nodes with textContent instead of
innerHTML; picture load checks upstream ok.
5 new tests (40 total) covering malformed bodies, malformed queue items,
unknown domains, object-key traversal, and error-page escaping.
This commit is contained in:
+92
-10
@@ -86,19 +86,21 @@ async function startFakeA2loft() {
|
||||
const fakeMinio = () => ({
|
||||
presignedGetObject: async (_bucket, name) => `https://storage.example/${name}`,
|
||||
});
|
||||
const fakeQueue = () => {
|
||||
const fakeQueue = (messageText) => {
|
||||
const calls = { getMessage: 0, deleteMessage: 0 };
|
||||
const item = {
|
||||
messageId: 'm-1',
|
||||
popReceipt: 'pop-1',
|
||||
dequeueCount: 1,
|
||||
messageText: JSON.stringify({
|
||||
upload_id: 'up-1',
|
||||
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
||||
caption: '08068287222',
|
||||
domain: 'ucth',
|
||||
raw_file_id: 'raw-1',
|
||||
}),
|
||||
messageText:
|
||||
messageText ??
|
||||
JSON.stringify({
|
||||
upload_id: 'up-1',
|
||||
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
|
||||
caption: '08068287222',
|
||||
domain: 'ucth',
|
||||
raw_file_id: 'raw-1',
|
||||
}),
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
@@ -155,7 +157,7 @@ async function startFakeNodered() {
|
||||
return { calls, server, stop: () => stopServer(server) };
|
||||
}
|
||||
|
||||
async function boot({ cropAllowed = true, cropperClients } = {}) {
|
||||
async function boot({ cropAllowed = true, cropperClients, queue } = {}) {
|
||||
const portal = await startFakePortal({ cropAllowed });
|
||||
const a2loft = await startFakeA2loft();
|
||||
const removebg = await startFakeRemovebg();
|
||||
@@ -182,7 +184,7 @@ async function boot({ cropAllowed = true, cropperClients } = {}) {
|
||||
fetch: testFetch,
|
||||
cropperClients: cropperClients !== undefined ? cropperClients : {
|
||||
minio: fakeMinio(),
|
||||
queue: fakeQueue(),
|
||||
queue: queue || fakeQueue(),
|
||||
noderedAuth: { username: 'nr', password: 'nr-secret' },
|
||||
carvekitKey: 'carve-key',
|
||||
},
|
||||
@@ -418,3 +420,83 @@ test('baseUrl exposes the identity-prefixed a2loft base', async () => {
|
||||
await stop();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('malformed request bodies are rejected, never hang the request', async () => {
|
||||
const { base, a2loft, stop } = await boot();
|
||||
try {
|
||||
const cookies = await login(base);
|
||||
for (const path of ['/cropper/picture/review', '/cropper/dequeue', '/cropper/profile/picture?otp=1']) {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.equal(res.status, 400, `${path} → ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, false);
|
||||
}
|
||||
assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0);
|
||||
} finally {
|
||||
await stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('a malformed queue item does not hang the request', async () => {
|
||||
const { base, stop } = await boot({ queue: fakeQueue('not-json') });
|
||||
try {
|
||||
const cookies = await login(base);
|
||||
const res = await fetch(`${base}/cropper/picture/next`, { headers: { cookie: cookies } });
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.match(body.message, /malformed/i);
|
||||
} finally {
|
||||
await stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('save rejects an unknown domain without contacting a2loft', async () => {
|
||||
const { base, a2loft, stop } = await boot();
|
||||
try {
|
||||
const cookies = await login(base);
|
||||
const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
|
||||
phone: '08068287222',
|
||||
message: {
|
||||
pop_receipt: 'custom',
|
||||
message_id: 'm-1',
|
||||
message: { upload_id: 'up-1', domain: '../../admin', agentDeviceHash: 'a' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(a2loft.calls.length, 0, 'a2loft never contacted');
|
||||
} finally {
|
||||
await stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('picture fetches reject object keys outside the id-domains prefix', async () => {
|
||||
const { base, stop } = await boot();
|
||||
try {
|
||||
const cookies = await login(base);
|
||||
// encoded traversal survives as a single path segment; a plain ../ is
|
||||
// normalised away by the HTTP client before the route ever sees it
|
||||
const traversal = await fetch(`${base}/cropper/picture/..%2F..%2Fsecret`, { headers: { cookie: cookies } });
|
||||
assert.equal(traversal.status, 400, `got ${traversal.status}`);
|
||||
const escapeWithinPrefix = await fetch(`${base}/cropper/picture/id-domains_.._.._secret`, {
|
||||
headers: { cookie: cookies },
|
||||
});
|
||||
assert.equal(escapeWithinPrefix.status, 400, `got ${escapeWithinPrefix.status}`);
|
||||
const ok = await fetch(`${base}/cropper/picture/id-domains_ucth_raw-pictures_x_img.jpg`, {
|
||||
headers: { cookie: cookies },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
} finally {
|
||||
await stop();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user