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:
+50
-8
@@ -70,6 +70,7 @@ export function createCropperRouter(cfg) {
|
||||
carvekitKey,
|
||||
removebgUrl = REMOVEBG_URL,
|
||||
noderedBase = 'https://nodered.a2loft.net',
|
||||
domainCodes = new Set(),
|
||||
requireApi,
|
||||
cache: cropCache,
|
||||
} = cfg;
|
||||
@@ -95,6 +96,25 @@ export function createCropperRouter(cfg) {
|
||||
};
|
||||
router.use(cropGate);
|
||||
|
||||
// Express 4 does not catch async rejections: an unhandled throw would hang
|
||||
// the request (and, on Node's default, exit the process). Wrap every route
|
||||
// handler so failures become a 500 via next(err).
|
||||
for (const method of ['get', 'post']) {
|
||||
const register = router[method].bind(router);
|
||||
router[method] = (path, ...handlers) =>
|
||||
register(path, ...handlers.map((h) => (h.length >= 3 ? h : (req, res, next) => Promise.resolve(h(req, res, next)).catch(next))));
|
||||
}
|
||||
|
||||
/** Reject malformed bodies up front instead of throwing on destructuring. */
|
||||
const requireMessageBody = (req, res) => {
|
||||
if (!req.body || typeof req.body.message !== 'object' || req.body.message === null) {
|
||||
res.status(400).json({ ok: false, message: 'A message body is required.' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const UPSTREAM_TIMEOUT_MS = 15000;
|
||||
const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`;
|
||||
const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`;
|
||||
|
||||
@@ -130,7 +150,12 @@ export function createCropperRouter(cfg) {
|
||||
if (!message) {
|
||||
return res.json({ ok: false, message: 'Queue is currently empty.' });
|
||||
}
|
||||
const data = JSON.parse(message.messageText);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(message.messageText);
|
||||
} catch {
|
||||
return res.json({ ok: false, message: 'Queue item is malformed.' });
|
||||
}
|
||||
const { messageId, popReceipt, dequeueCount } = message;
|
||||
res.json({
|
||||
ok: true,
|
||||
@@ -140,9 +165,12 @@ export function createCropperRouter(cfg) {
|
||||
|
||||
router.get('/picture/load/:id', async (req, res) => {
|
||||
try {
|
||||
const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(req.params.id)}`);
|
||||
const { data } = await r.json();
|
||||
if (!data) return res.json({ ok: false, message: 'No upload with the specified id.' });
|
||||
const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(req.params.id)}`, {
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
const body = await r.json().catch(() => null);
|
||||
const data = body && body.data;
|
||||
if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' });
|
||||
const { upload_id, file_name, caption, domain, raw_file_id } = data;
|
||||
return res.json({
|
||||
ok: true,
|
||||
@@ -163,6 +191,7 @@ export function createCropperRouter(cfg) {
|
||||
const review = await (
|
||||
await fetchImpl(nodered(`/idid/picture/review/${encodeURIComponent(req.params.review_id)}`), {
|
||||
headers: { authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}` },
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
})
|
||||
).json();
|
||||
if (!review.ok) {
|
||||
@@ -171,6 +200,7 @@ export function createCropperRouter(cfg) {
|
||||
const agents = await (
|
||||
await fetchImpl(nodered('/unical/agents'), {
|
||||
headers: { authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}` },
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
})
|
||||
).json();
|
||||
const agent = (Array.isArray(agents) ? agents : []).find(
|
||||
@@ -184,9 +214,12 @@ export function createCropperRouter(cfg) {
|
||||
|
||||
router.get('/picture/:file_name', async (req, res) => {
|
||||
const file_name = req.params.file_name.replace(/_/gi, '/');
|
||||
if (!file_name.startsWith('id-domains/') || file_name.includes('..')) {
|
||||
return res.status(400).json({ ok: false, message: 'Invalid picture reference.' });
|
||||
}
|
||||
try {
|
||||
const url = await minio.presignedGetObject(BUCKET, file_name);
|
||||
const r = await fetchImpl(url);
|
||||
const r = await fetchImpl(url, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) });
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
res.json({ ok: true, data: `data:image/png;base64,${buf.toString('base64')}` });
|
||||
} catch (error) {
|
||||
@@ -195,13 +228,15 @@ export function createCropperRouter(cfg) {
|
||||
});
|
||||
|
||||
router.post('/picture/review', async (req, res) => {
|
||||
if (!requireMessageBody(req, res)) return;
|
||||
const {
|
||||
message: { pop_receipt, message_id },
|
||||
message: { pop_receipt, message_id } = {},
|
||||
deleteFromQueue,
|
||||
...body
|
||||
} = req.body;
|
||||
try {
|
||||
const r = await fetchImpl(nodered('/zeebe/picture/review'), {
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
@@ -230,8 +265,9 @@ export function createCropperRouter(cfg) {
|
||||
});
|
||||
|
||||
router.post('/dequeue', async (req, res) => {
|
||||
if (!requireMessageBody(req, res)) return;
|
||||
const {
|
||||
message: { pop_receipt, message_id },
|
||||
message: { pop_receipt, message_id } = {},
|
||||
} = req.body;
|
||||
try {
|
||||
const result = await deleteMessage(message_id, pop_receipt);
|
||||
@@ -250,6 +286,7 @@ export function createCropperRouter(cfg) {
|
||||
const fd = new FormData();
|
||||
fd.append('image_file_b64', req.body.image_file_b64);
|
||||
const r = await fetchImpl(removebgUrl, {
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': carvekitKey },
|
||||
body: fd,
|
||||
@@ -271,6 +308,7 @@ export function createCropperRouter(cfg) {
|
||||
|
||||
// ── save (the one a2loft call that needs the operator OTP) ──────────────
|
||||
router.post('/profile/picture', async (req, res) => {
|
||||
if (!requireMessageBody(req, res)) return;
|
||||
if (!requireOtp(req, res)) return;
|
||||
let live;
|
||||
try {
|
||||
@@ -297,14 +335,18 @@ export function createCropperRouter(cfg) {
|
||||
message: {
|
||||
pop_receipt,
|
||||
message_id,
|
||||
message: { upload_id, domain, agentDeviceHash },
|
||||
message: { upload_id, domain, agentDeviceHash } = {},
|
||||
},
|
||||
} = req.body;
|
||||
if (!domainCodes.has(domain)) {
|
||||
return res.status(400).json({ ok: false, message: 'Unknown domain.' });
|
||||
}
|
||||
const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp });
|
||||
try {
|
||||
const r = await fetchImpl(
|
||||
`${base}/${encodeURIComponent(domain)}/profile/picture?${q.toString()}`,
|
||||
{
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user