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
+6 -18
View File
@@ -6,6 +6,7 @@ const { exists } = require('../../src/utilities/fs-helpers');
const { ValidationError } = require('../../src/utilities/errors');
const platformPaths = require('../../platform-paths');
const { ok, successMessage } = require('../../src/utils/responses');
const { validateBody, schemas: valSchemas } = require('../../src/utilities/validate');
/**
* Config assets routes factory
* @param {Object} deps - Explicit dependencies
@@ -54,13 +55,9 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// ===== ASSET UPLOAD =====
router.post('/assets/upload', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => {
router.post('/assets/upload', express.json({ limit: LIMITS.BODY_UPLOAD }), validateBody(valSchemas.assetUpload), asyncHandler(async (req, res) => {
const { filename, data } = req.body;
if (!filename || !data) {
throw new ValidationError('filename and data are required');
}
// Validate filename to prevent directory traversal
const safeFilename = path.basename(filename);
if (safeFilename !== filename || filename.includes('..')) {
@@ -129,13 +126,9 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
// Upload custom logo(s) and/or update position and title
// Supports: dataDark/dataLight (separate variants) or data (single logo for both)
// eslint-disable-next-line complexity
router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => {
router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), validateBody(valSchemas.logoUpload), asyncHandler(async (req, res) => {
const { data, dataDark, dataLight, position, dashboardTitle } = req.body;
if (!data && !dataDark && !dataLight && !position && !dashboardTitle) {
throw new ValidationError('Image data, position, or title is required');
}
const config = await ctx.readConfig();
let pathDark = null, pathLight = null;
@@ -240,15 +233,10 @@ module.exports = function({ servicesStateManager: _servicesStateManager, asyncHa
return ctx.errorResponse(res, 500, 'Image processing not available');
}
// Extract base64 data
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
if (!matches) {
throw new ValidationError('Invalid image data format');
}
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');
// P0-4: validate MIME type + enforce 5MB buffer size cap (mime validated inside decodeImageData)
const { buffer } = decodeImageData(data);
// Determine assets path (mounted volume)
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
if (!await exists(assetsPath)) {
await fsp.mkdir(assetsPath, { recursive: true });