Files
picpeak/backend/src/routes/adminWebhooks.js
T
Paul Nothaft 406c638451 fix(security): stop reflecting submitted values in validation errors everywhere, cap credential lengths, close the login timing oracle
safeValidationErrors moves to utils/routeHelpers and replaces every
res.status(400).json({ errors: errors.array() }) in the routes, so no 400
body carries the submitted value any more (setup, customer auth and
customer change-password were still echoing rejected passwords).

Admin login, gallery verify, customer login/register/reset, customer
change-password and setup now cap username/slug at 255 and passwords at
MAX_PASSWORD_LENGTH at the validator, so an oversized value never reaches
the lockout lookup, bcrypt or the failed-attempt log.

Admin and customer login run one bcrypt compare on every path; the unknown
account branch used to return in microseconds against ~100ms for a wrong
password, which enumerated usernames despite the generic message.

(cherry picked from commit 40a8a9882a)
2026-09-03 12:14:42 +02:00

391 lines
16 KiB
JavaScript

/**
* Admin endpoints for managing outbound webhooks (#327). Mirrors
* adminApiTokens.js — same permission gates, same "secret shown once"
* pattern.
*
* Routes mounted under /api/admin/webhooks:
* GET / — list
* POST / — create (returns plaintext secret once)
* GET /:id — detail (no secret)
* PUT /:id — update name/url/events/active
* DELETE /:id — delete (cascades to deliveries)
* POST /:id/test — fire a synthetic delivery now
* GET /:id/deliveries — list deliveries (paginated, filter)
* GET /:id/deliveries/:deliveryId — delivery detail (payload+response)
* POST /:id/deliveries/:deliveryId/replay — re-enqueue a delivery
*/
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const webhookService = require('../services/webhookService');
const logger = require('../utils/logger');
const router = express.Router();
const ALLOW_PRIVATE_URLS = process.env.WEBHOOK_ALLOW_PRIVATE_URLS === 'true';
function publicWebhook(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
url: row.url,
events: typeof row.events === 'string' ? safeJson(row.events, []) : (row.events || []),
active: row.active,
secret_preview: row.secret_preview,
filter: typeof row.filter === 'string' ? safeJson(row.filter, {}) : (row.filter || {}),
template: row.template || null,
created_by: row.created_by,
created_at: row.created_at,
updated_at: row.updated_at,
last_success_at: row.last_success_at,
last_failure_at: row.last_failure_at,
};
}
function safeJson(s, fallback) {
try { return JSON.parse(s); } catch { return fallback; }
}
// ─── List ────────────────────────────────────────────────────────────────
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const rows = await db('webhooks')
.leftJoin('admin_users', 'admin_users.id', 'webhooks.created_by')
.select(
'webhooks.*',
'admin_users.username as owner_username'
)
.orderBy('webhooks.created_at', 'desc');
res.json(rows.map((r) => ({
...publicWebhook(r),
owner_username: r.owner_username,
})));
} catch (err) {
logger.error('webhooks list failed', { error: err.message });
res.status(500).json({ error: 'Failed to list webhooks' });
}
});
// ─── Create ──────────────────────────────────────────────────────────────
router.post(
'/',
adminAuth,
requirePermission('settings.edit'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('url').isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
body('events').isArray({ min: 1 }).custom((arr) => {
const ok = arr.every((e) => webhookService.EVENT_TYPES.includes(e));
if (!ok) throw new Error(`events must be a subset of: ${webhookService.EVENT_TYPES.join(', ')}`);
return true;
}),
body('active').optional().isBoolean(),
body('filter').optional().custom((v) => {
if (v == null) return true;
if (typeof v !== 'object' || Array.isArray(v)) {
throw new Error('filter must be an object of dot-path → value pairs');
}
return true;
}),
body('template').optional({ nullable: true }).custom((v) => {
const check = webhookService.validateTemplate(v);
if (!check.valid) throw new Error(check.error);
return true;
}),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const { name, url, events, active = true, filter, template } = req.body;
const { plaintext, preview } = webhookService.generateSecret();
const insertResult = await db('webhooks').insert({
name,
url,
secret: plaintext,
secret_preview: preview,
events: JSON.stringify(events),
active,
filter: JSON.stringify(filter || {}),
template: template || null,
created_by: req.admin.id,
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
await logActivity('webhook_created', { name, events }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
const row = await db('webhooks').where({ id }).first();
res.status(201).json({
...publicWebhook(row),
secret: plaintext,
notice: 'Save this signing secret now — it will not be shown again.',
});
} catch (err) {
logger.error('webhooks create failed', { error: err.message });
res.status(500).json({ error: 'Failed to create webhook' });
}
}
);
// ─── Detail ──────────────────────────────────────────────────────────────
router.get('/:id', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
res.json(publicWebhook(row));
} catch (err) {
logger.error('webhooks detail failed', { error: err.message });
res.status(500).json({ error: 'Failed to load webhook' });
}
});
// ─── Update ──────────────────────────────────────────────────────────────
router.put(
'/:id',
adminAuth,
requirePermission('settings.edit'),
[
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
body('url').optional().isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
body('events').optional().isArray({ min: 1 }).custom((arr) => {
const ok = arr.every((e) => webhookService.EVENT_TYPES.includes(e));
if (!ok) throw new Error(`events must be a subset of: ${webhookService.EVENT_TYPES.join(', ')}`);
return true;
}),
body('active').optional().isBoolean(),
body('filter').optional().custom((v) => {
if (v == null) return true;
if (typeof v !== 'object' || Array.isArray(v)) {
throw new Error('filter must be an object of dot-path → value pairs');
}
return true;
}),
body('template').optional({ nullable: true }).custom((v) => {
const check = webhookService.validateTemplate(v);
if (!check.valid) throw new Error(check.error);
return true;
}),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
const updates = { updated_at: new Date() };
if ('name' in req.body) updates.name = req.body.name;
if ('url' in req.body) updates.url = req.body.url;
if ('events' in req.body) updates.events = JSON.stringify(req.body.events);
if ('active' in req.body) updates.active = req.body.active;
if ('filter' in req.body) updates.filter = JSON.stringify(req.body.filter || {});
if ('template' in req.body) updates.template = req.body.template || null;
await db('webhooks').where({ id: req.params.id }).update(updates);
const updated = await db('webhooks').where({ id: req.params.id }).first();
await logActivity('webhook_updated', { changes: Object.keys(updates) }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
res.json(publicWebhook(updated));
} catch (err) {
logger.error('webhooks update failed', { error: err.message });
res.status(500).json({ error: 'Failed to update webhook' });
}
}
);
// ─── Delete ──────────────────────────────────────────────────────────────
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
await db('webhooks').where({ id: req.params.id }).delete();
await logActivity('webhook_deleted', { name: row.name }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username,
});
res.json({ id: Number(req.params.id), deleted: true });
} catch (err) {
logger.error('webhooks delete failed', { error: err.message });
res.status(500).json({ error: 'Failed to delete webhook' });
}
});
// ─── Send test event ─────────────────────────────────────────────────────
router.post(
'/:id/test',
adminAuth,
requirePermission('settings.edit'),
[body('event_type').optional().isIn(webhookService.EVENT_TYPES)],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
if (!row.active) return res.status(400).json({ error: 'Webhook is disabled' });
const eventType = req.body.event_type || (() => {
const subscribed = typeof row.events === 'string' ? safeJson(row.events, []) : (row.events || []);
return subscribed[0] || 'event.published';
})();
// Fire a synthetic event WITHOUT writing to webhooks table — the test
// bypasses subscription matching by inserting a delivery directly.
const crypto = require('crypto');
const deliveryId = crypto.randomUUID();
const payload = {
id: deliveryId,
type: eventType,
created_at: new Date().toISOString(),
data: { test: true, fired_by: req.admin.username, webhook_id: row.id },
};
await db('webhook_deliveries').insert({
webhook_id: row.id,
event_type: eventType,
payload: JSON.stringify(payload),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
res.status(202).json({ enqueued: true, event_type: eventType });
} catch (err) {
logger.error('webhook test failed', { error: err.message });
res.status(500).json({ error: 'Failed to enqueue test event' });
}
}
);
// ─── List deliveries ─────────────────────────────────────────────────────
router.get(
'/:id/deliveries',
adminAuth,
requirePermission('settings.view'),
[
query('status').optional().isIn(['pending', 'success', 'failed']),
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const webhookId = req.params.id;
const exists = await db('webhooks').where({ id: webhookId }).first();
if (!exists) return res.status(404).json({ error: 'Webhook not found' });
const page = parseInt(req.query.page || '1', 10);
const limit = parseInt(req.query.limit || '25', 10);
const offset = (page - 1) * limit;
let q = db('webhook_deliveries').where({ webhook_id: webhookId });
if (req.query.status) q = q.where({ status: req.query.status });
const totalRow = await q.clone().count('id as count').first();
const total = parseInt(totalRow?.count || 0, 10);
const rows = await q
.select(
'id', 'event_type', 'attempt_count', 'status', 'response_status',
'latency_ms', 'next_retry_at', 'created_at', 'completed_at', 'last_error'
)
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset);
res.json({ deliveries: rows, pagination: { page, limit, total } });
} catch (err) {
logger.error('deliveries list failed', { error: err.message });
res.status(500).json({ error: 'Failed to list deliveries' });
}
}
);
// ─── Delivery detail ─────────────────────────────────────────────────────
router.get(
'/:id/deliveries/:deliveryId',
adminAuth,
requirePermission('settings.view'),
async (req, res) => {
try {
const row = await db('webhook_deliveries')
.where({ id: req.params.deliveryId, webhook_id: req.params.id })
.first();
if (!row) return res.status(404).json({ error: 'Delivery not found' });
res.json({
...row,
payload: typeof row.payload === 'string' ? safeJson(row.payload, row.payload) : row.payload,
});
} catch (err) {
logger.error('delivery detail failed', { error: err.message });
res.status(500).json({ error: 'Failed to load delivery' });
}
}
);
// ─── Replay ──────────────────────────────────────────────────────────────
router.post(
'/:id/deliveries/:deliveryId/replay',
adminAuth,
requirePermission('settings.edit'),
async (req, res) => {
try {
const row = await db('webhook_deliveries')
.where({ id: req.params.deliveryId, webhook_id: req.params.id })
.first();
if (!row) return res.status(404).json({ error: 'Delivery not found' });
// Re-enqueue: copy the original payload + event_type into a new row
// marked pending. Preserves the audit log of the original attempt.
const crypto = require('crypto');
const newPayload = (() => {
const obj = typeof row.payload === 'string' ? safeJson(row.payload, {}) : row.payload || {};
// Replays get a fresh delivery id but keep the event payload data.
return JSON.stringify({ ...obj, id: crypto.randomUUID(), replayed_from: row.id });
})();
const insertResult = await db('webhook_deliveries').insert({
webhook_id: row.webhook_id,
event_type: row.event_type,
payload: newPayload,
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
}).returning('id');
const newId = insertResult[0]?.id || insertResult[0];
res.status(202).json({ enqueued: true, original_id: row.id, replay_id: newId });
} catch (err) {
logger.error('delivery replay failed', { error: err.message });
res.status(500).json({ error: 'Failed to replay delivery' });
}
}
);
module.exports = router;