Files
dashcaddy/dashcaddy-api/src/utilities/validate.js
T
Hermes a667de7920
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-059: Joi validation middleware + schemas for destructive routes
[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
2026-08-08 15:39:48 -07:00

183 lines
7.4 KiB
JavaScript

/**
* Joi-based request body validation middleware.
*
* Usage:
* const { validateBody, schemas } = require('../utilities/validate');
*
* 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.
* On failure it throws a ValidationError (caught by asyncHandler → 400).
*
* Schemas for destructive routes live in the `schemas` export so they
* can be unit-tested without spinning up Express.
*/
const Joi = require('joi');
const { ValidationError } = require('./errors');
/**
* Factory: returns an Express middleware that validates req.body.
* @param {Joi.Schema} schema
* @param {{ stripUnknown?: boolean, abortEarly?: boolean }} [opts]
*/
function validateBody(schema, opts = {}) {
return (req, _res, next) => {
const { error, value } = schema.validate(req.body, {
stripUnknown: opts.stripUnknown ?? true,
abortEarly: opts.abortEarly ?? false,
convert: true,
});
if (error) {
const msg = error.details.map(d => d.message).join('; ');
throw new ValidationError(msg);
}
req.body = value;
next();
};
}
// ─── Reusable schema fragments ───────────────────────────────
const scheduleSchema = Joi.alternatives().try(
Joi.string().valid('hourly', 'daily', 'weekly', 'monthly'),
Joi.string().pattern(/^\d+[mh]?$/, 'custom-interval (e.g. "30m", "6h")'),
).optional();
const ipOrCidr = Joi.alternatives().try(
// Authoritative single-IP validation (Joi's built-in strict IPv4/IPv6 check)
Joi.string().ip({ version: ['ipv4', 'ipv6'] }),
// 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 ───────────────────────────
const schemas = {
/** POST /backups/config — update backup configuration */
backupConfigUpdate: Joi.object({
backups: Joi.object().pattern(
Joi.string().max(100), // appId key
Joi.object({ // per-app backup config
enabled: Joi.boolean().optional(),
schedule: scheduleSchema,
retention: Joi.object({
keep: Joi.number().integer().min(1).max(365).optional(),
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
}).optional(),
destination: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
destinationPath: Joi.string().max(512).optional(),
maxStorageBytes: [Joi.number().integer().min(0).optional(), Joi.string().max(20).optional()],
runImmediately: Joi.boolean().optional(),
include: Joi.array().items(Joi.string().max(50)).optional(),
destinations: Joi.array().items(Joi.object({
type: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
path: Joi.string().max(512).optional(),
}).unknown(false)).optional(),
}).unknown(false)
).optional(),
defaultRetention: Joi.object({
keep: Joi.number().integer().min(1).max(365).optional(),
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
}).optional(),
}),
/** POST /backups/schedule — create or update a scheduled backup */
backupScheduleCreate: Joi.object({
appId: Joi.string().min(1).max(100).required(),
enabled: Joi.boolean().optional(),
schedule: scheduleSchema,
retention: Joi.object({
keep: Joi.number().integer().min(1).max(365).optional(),
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
}).optional(),
runImmediately: Joi.boolean().optional(),
destination: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
destinationPath: Joi.string().max(512).optional(),
maxStorageBytes: [Joi.number().integer().min(0).optional(), Joi.string().max(20).optional()],
// Legacy schedule route fields
name: Joi.string().max(100).optional(),
}),
/** POST /backups/restore/:backupId — passes through to backupManager.restoreBackup */
backupRestore: Joi.object({
encryptionKey: Joi.string().max(512).optional(),
restartContainers: Joi.boolean().optional(),
// restoreBackup reads options from body; allow known control flags only
services: Joi.boolean().optional(),
config: Joi.boolean().optional(),
credentials: Joi.boolean().optional(),
volumes: Joi.boolean().optional(),
}),
/** POST /backups/restore-file/:filename */
backupRestoreFile: Joi.object({
encryptionKey: Joi.string().max(512).optional(),
restartContainers: Joi.boolean().optional(),
}),
/** POST /apps/deploy */
appDeploy: Joi.object({
appId: Joi.string().min(1).max(100).required(),
config: Joi.object({
subdomain: Joi.string().min(1).max(63).required(),
port: Joi.number().integer().min(1).max(65535).optional(),
ip: Joi.string().max(45).optional(),
useExisting: Joi.boolean().optional(),
existingContainerId: Joi.string().max(200).optional(),
existingPort: Joi.number().integer().min(1).max(65535).optional(),
createDns: Joi.boolean().optional(),
tailscaleOnly: Joi.boolean().optional(),
allowedIPs: Joi.array().items(ipOrCidr).optional(),
customVolumes: Joi.array().items(Joi.object({
hostPath: Joi.string().max(500).required(),
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(),
}).unknown(true), // Forward-compat: templates may accept additional fields
}),
/** 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({
encryptionKey: Joi.string().max(512).optional(),
restartContainers: Joi.boolean().optional(),
}),
/** POST /assets/upload */
assetUpload: Joi.object({
filename: Joi.string().min(1).max(255).required(),
data: Joi.string().min(1).max(10 * 1024 * 1024).required(), // 10MB base64 cap
}),
/** POST /assets/logo */
logoUpload: Joi.object({
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),
};
module.exports = { validateBody, schemas };