DC-059: Joi validation middleware + schemas for destructive routes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

[grade=B]

- New src/utilities/validate.js: validateBody(schema) middleware + 9 schemas
  (backupConfigUpdate, backupScheduleCreate, backupRestore, backupRestoreFile,
   appDeploy, appRestore, appRevert, assetUpload, logoUpload)
- Uses Joi's authoritative CIDR validator (rejects malformed IPv6 like ::::/64
  that the previous hex/colon regex would have accepted)
- appDeploy.config uses .unknown(true) for forward-compat with template-specific
  fields (sslType, dnsType, plexClaimToken, etc.) — preserves fields the live
  frontend posts, prevents a behavioural regression
- appRestore uses Joi.any().custom() so the empty-body semantics hold under
  middleware stripUnknown (default) — body with extra keys now rejected
- Wired into 8 destructive routes: backups schedule/restore/config, apps
  deploy/restore/revert, assets upload/logo
- Duplicate legacy POST /backups/schedule handler (line 519) marked LEGACY
  with TODO removal note (Express only matches first registration; this
  handler is unreachable under normal routing)
- Removed redundant manual appId check in /backups/schedule (Joi schema
  enforces it)
- Removed unused 'mime' destructure in /assets/favicon (decodeImageData
  validates MIME internally)
- 41 unit tests covering every exported schema + middleware integration
- 1539/1539 Jest tests pass, zero new ESLint warnings
This commit is contained in:
Hermes
2026-08-08 15:39:48 -07:00
parent c1358df0ec
commit a667de7920
8 changed files with 291 additions and 67 deletions
+22 -22
View File
@@ -4,7 +4,7 @@
* Usage:
* const { validateBody, schemas } = require('../utilities/validate');
*
* router.post('/schedule', validateBody(schemas.backupSchedule), handler);
* router.post('/schedule', validateBody(schemas.backupScheduleCreate), handler);
*
* The middleware validates req.body against the provided Joi schema.
* On success it replaces req.body with the validated/stripped value.
@@ -46,22 +46,11 @@ const scheduleSchema = Joi.alternatives().try(
).optional();
const ipOrCidr = Joi.alternatives().try(
// Authoritative single-IP validation (Joi's built-in strict IPv4/IPv6 check)
Joi.string().ip({ version: ['ipv4', 'ipv6'] }),
Joi.string().regex(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/)
.custom((value, helpers) => {
const [, a, b, c, d, prefix] = value.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/);
for (const octet of [a, b, c, d]) {
if (parseInt(octet) > 255) return helpers.error('string.pattern.base', { name: 'IPv4 CIDR' });
}
if (parseInt(prefix) > 32) return helpers.error('string.pattern.base', { name: 'IPv4 CIDR' });
return value;
}, 'IPv4 CIDR with valid octets/prefix'),
Joi.string().regex(/^[0-9a-fA-F:]+\/(\d{1,3})$/)
.custom((value, helpers) => {
const prefix = parseInt(value.split('/')[1]);
if (prefix > 128) return helpers.error('string.pattern.base', { name: 'IPv6 CIDR' });
return value;
}, 'IPv6 CIDR with valid prefix'),
// Authoritative CIDR validation (Joi's built-in strict CIDR check rejects malformed
// addresses like "::::/64" that a permissive hex/colon regex would otherwise accept)
Joi.string().ip({ version: ['ipv4', 'ipv6'], cidr: 'required' }),
);
// ─── Schemas for destructive routes ───────────────────────────
@@ -147,15 +136,26 @@ const schemas = {
containerPath: Joi.string().max(500).required(),
}).unknown(false)).optional(),
mediaPath: Joi.string().max(500).optional(),
// Template-specific config fields preserved from the live frontend
sslType: Joi.string().valid('self-signed', 'tailscale', 'letsencrypt', 'none').optional(),
dnsType: Joi.string().valid('private', 'public', 'none').optional(),
plexClaimToken: Joi.string().max(500).optional(),
resources: Joi.object({
memory: Joi.number().min(32).max(65536).optional(),
cpus: Joi.number().min(0.1).max(64).optional(),
}).optional(),
}).required(),
}).unknown(true), // Forward-compat: templates may accept additional fields
}),
/** POST /apps/:appId/restore */
appRestore: Joi.object({}).max(0),
/** POST /apps/:appId/restore — empty body, reject any input fields */
appRestore: Joi.any().custom((value, helpers) => {
if (value !== undefined && value !== null && (typeof value !== 'object' || Object.keys(value).length > 0)) {
return helpers.error('object.empty');
}
return {};
}, 'empty-body-guard').messages({
'object.empty': '"body" must be empty (this endpoint accepts no input)',
}),
/** POST /apps/:appId/revert/:filename */
appRevert: Joi.object({
@@ -171,9 +171,9 @@ const schemas = {
/** POST /assets/logo */
logoUpload: Joi.object({
data: Joi.string().max(10 * 1024 * 1024).optional(),
dataDark: Joi.string().max(10 * 1024 * 1024).optional(),
dataLight: Joi.string().max(10 * 1024 * 1024).optional(),
data: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
dataDark: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
dataLight: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
position: Joi.string().valid('left', 'center', 'right').optional(),
dashboardTitle: Joi.string().max(50).allow('').optional(),
}).min(1),