diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index 280f6130..a4e24ecd 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -14,6 +14,60 @@ import { getSettings } from '../../services/storage/settingsStorage.js'; const DEMO_JOB_NAME = 'Demo-Job'; +/** + * Notification adapter fields that touch the filesystem or other sensitive + * process-level resources. Non-admin users must not be able to introduce or + * modify these fields on a job, otherwise any authenticated user could make + * the server open/create files at arbitrary locations writable by the process + * (e.g. the sqlite adapter's `dbPath`). + */ +const PRIVILEGED_ADAPTER_FIELDS = { + sqlite: ['dbPath'], +}; + +function getAdapterFields(adapterList, adapterId) { + if (!Array.isArray(adapterList)) return {}; + const entry = adapterList.find((a) => a && a.id === adapterId); + return (entry && entry.fields) || {}; +} + +/** + * Ensures a non-admin user cannot introduce or change privileged notification + * adapter fields (e.g. sqlite `dbPath`). Returns either: + * { ok: true, notificationAdapter } — safe to persist + * { ok: false, adapterId, field } — non-admin attempted to set or + * change a privileged field + * + * Admins are unaffected, so existing configurations continue to work unchanged. + * Non-admin requests that omit privileged fields (or echo back the admin-set + * value unchanged) are also allowed through. + */ +function sanitiseNotificationAdapter(notificationAdapter, existingJob, requestIsAdmin) { + if (requestIsAdmin || !Array.isArray(notificationAdapter)) { + return { ok: true, notificationAdapter }; + } + + for (const adapter of notificationAdapter) { + if (!adapter || typeof adapter !== 'object') continue; + const privilegedFields = PRIVILEGED_ADAPTER_FIELDS[adapter.id]; + if (!privilegedFields || !privilegedFields.length) continue; + + const incomingFields = adapter.fields || {}; + const existingFields = existingJob ? getAdapterFields(existingJob.notificationAdapter, adapter.id) : {}; + for (const key of privilegedFields) { + if (!Object.prototype.hasOwnProperty.call(incomingFields, key)) continue; + const incomingValue = incomingFields[key]; + const existingValue = existingFields[key]; + // Allow the no-op case where the client echoes back the admin-set value + // (common when editing a shared job through the UI). + if (incomingValue === existingValue) continue; + return { ok: false, adapterId: adapter.id, field: key }; + } + } + + return { ok: true, notificationAdapter }; +} + function doesJobBelongsToUser(job, request) { const userId = request.session.currentUser; if (userId == null) return false; @@ -171,6 +225,13 @@ export default async function jobPlugin(fastify) { return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' }); } + const sanitised = sanitiseNotificationAdapter(notificationAdapter, jobFromDb, isAdmin(request)); + if (!sanitised.ok) { + return reply.code(403).send({ + error: `You are not allowed to set the "${sanitised.field}" field on the "${sanitised.adapterId}" notification adapter. Please ask an administrator to configure this value.`, + }); + } + jobStorage.upsertJob({ userId: request.session.currentUser, jobId, @@ -178,7 +239,7 @@ export default async function jobPlugin(fastify) { name, blacklist, provider, - notificationAdapter, + notificationAdapter: sanitised.notificationAdapter, shareWithUsers, spatialFilter, specFilter,