DC-059: claim for Hermes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

This commit is contained in:
Hermes
2026-08-08 15:20:09 -07:00
parent 55a50fdeb7
commit c1358df0ec
3 changed files with 393 additions and 0 deletions
+8
View File
@@ -382,3 +382,11 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
7. **Never work on a task another bot has claimed** (status: in-progress).
8. **Quality bar:** this is a public-release product. No hacks, no env-var workarounds, no per-machine patches. Fixes go in the shared codebase.
9. **VERSION bump:** when a batch of tasks is done, bump patch version in package.json + VERSION file, update CHANGELOG, tag.
### DC-059: Joi validation library — schema-based body validation middleware
- **status:** in-progress
- **owner:** hermes
- **details:** Backend uses ad-hoc `if (!field) throw new ValidationError(...)` checks at every route entry point — 49 such checks across the codebase. They drift from the field semantics, allow unknown keys to flow through, and have no way to express structured types (CIDR, enum, port range). Tracked in `DC-PRODUCTION-GRADE-BACKLOG.md` as P1-1. Fix: `npm install joi@^18`, add `src/utilities/validate.js` exporting `validateBody(schema)` middleware factory + `schemas` object with reusable schemas. Apply to destructive routes: backups (schedule/restore/config), apps (deploy/restore/revert), assets (upload/logo). Add `__tests__/unit/validate.test.js` covering each schema's accept/reject/strip-unknown behaviour. Effort: ~2 hr.
- **impact:** Closes P0-3 / P0-4 class of bugs at the schema layer instead of per-route. Prevents future routes from accepting arbitrary body fields. New routes copy-paste from `schemas.*` and get free validation.
- **prerequisite:** None.
@@ -0,0 +1,203 @@
/**
* Unit tests for the Joi validation middleware + schema definitions.
* Verifies that valid inputs pass through and invalid inputs throw
* ValidationError with descriptive messages.
*/
const { validateBody, schemas } = require('../../src/utilities/validate');
function mockReq(body) {
return { body };
}
describe('validateBody middleware', () => {
test('passes valid body through and strips unknown keys', () => {
const schema = schemas.assetUpload;
const req = mockReq({ filename: 'logo.png', data: 'data:image/png;base64,abc', extra: true });
const next = jest.fn();
validateBody(schema)(req, {}, next);
expect(next).toHaveBeenCalled();
expect(req.body).toHaveProperty('filename', 'logo.png');
expect(req.body).not.toHaveProperty('extra');
});
test('throws ValidationError on missing required field', () => {
const req = mockReq({});
expect(() => validateBody(schemas.assetUpload)(req, {}, jest.fn())).toThrow(/filename/);
});
});
describe('schemas.backupConfigUpdate', () => {
test('accepts valid patch', () => {
const { error, value } = schemas.backupConfigUpdate.validate({
backups: { app1: { enabled: true, schedule: 'daily' } },
defaultRetention: { keep: 7 },
});
expect(error).toBeUndefined();
expect(value).toHaveProperty('backups');
});
test('strips unknown top-level keys', () => {
const { error, value } = schemas.backupConfigUpdate.validate({
backups: {},
malicious: true,
}, { stripUnknown: true });
expect(error).toBeUndefined();
expect(value).not.toHaveProperty('malicious');
});
test('rejects unknown keys inside per-app backup config', () => {
const { error } = schemas.backupConfigUpdate.validate({
backups: { app1: { enabled: true, schedule: 'daily', rce: 'yes' } },
}, { stripUnknown: false });
expect(error).toBeDefined();
});
test('rejects invalid retention.keep', () => {
const { error } = schemas.backupConfigUpdate.validate({
defaultRetention: { keep: 0 },
});
expect(error).toBeDefined();
expect(error.details[0].message).toMatch(/keep/);
});
});
describe('schemas.backupScheduleCreate', () => {
test('requires appId', () => {
const { error } = schemas.backupScheduleCreate.validate({});
expect(error).toBeDefined();
});
test('accepts full valid body', () => {
const { error, value } = schemas.backupScheduleCreate.validate({
appId: 'plex',
enabled: true,
schedule: 'daily',
retention: { keep: 7 },
destination: 'local',
maxStorageBytes: '10GB',
});
expect(error).toBeUndefined();
expect(value.appId).toBe('plex');
});
test('rejects invalid destination', () => {
const { error } = schemas.backupScheduleCreate.validate({
appId: 'plex',
destination: 'malicious-cloud',
});
expect(error).toBeDefined();
});
test('accepts numeric custom schedule (e.g. "30m", "6h")', () => {
const { error: e1 } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: '30m' });
expect(e1).toBeUndefined();
const { error: e2 } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: '6h' });
expect(e2).toBeUndefined();
});
test('rejects garbage schedule string', () => {
const { error } = schemas.backupScheduleCreate.validate({ appId: 'plex', schedule: 'abc' });
expect(error).toBeDefined();
});
});
describe('schemas.appDeploy', () => {
test('requires appId + config.subdomain', () => {
const { error } = schemas.appDeploy.validate({});
expect(error).toBeDefined();
});
test('accepts minimal valid deploy', () => {
const { error, value } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex' },
});
expect(error).toBeUndefined();
expect(value.config.subdomain).toBe('plex');
});
test('rejects port out of range', () => {
const { error } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', port: 99999 },
});
expect(error).toBeDefined();
});
test('accepts valid IP in allowedIPs', () => {
const { error } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', allowedIPs: ['192.168.1.1', '10.0.0.0/24'] },
});
expect(error).toBeUndefined();
});
test('rejects malformed CIDR in allowedIPs', () => {
const { error } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', allowedIPs: ['999.999.999.999/99'] },
});
expect(error).toBeDefined();
});
test('accepts valid customVolumes objects', () => {
const { error } = schemas.appDeploy.validate({
appId: 'plex',
config: {
subdomain: 'plex',
customVolumes: [{ hostPath: '/data/movies', containerPath: '/movies' }],
},
});
expect(error).toBeUndefined();
});
test('strips unknown keys in config', () => {
const { error, value } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', isAdmin: true },
}, { stripUnknown: true });
expect(error).toBeUndefined();
expect(value.config).not.toHaveProperty('isAdmin');
});
});
describe('schemas.assetUpload', () => {
test('requires both fields', () => {
const { error: e1 } = schemas.assetUpload.validate({ filename: 'logo.png' });
expect(e1).toBeDefined();
const { error: e2 } = schemas.assetUpload.validate({ data: 'abc' });
expect(e2).toBeDefined();
});
test('accepts valid upload', () => {
const { error } = schemas.assetUpload.validate({
filename: 'logo.png',
data: 'data:image/png;base64,iVBORw0KGgo=',
});
expect(error).toBeUndefined();
});
});
describe('schemas.backupRestoreFile', () => {
test('accepts empty body', () => {
const { error } = schemas.backupRestoreFile.validate({});
expect(error).toBeUndefined();
});
test('accepts optional fields', () => {
const { error } = schemas.backupRestoreFile.validate({
encryptionKey: 'secret',
restartContainers: true,
});
expect(error).toBeUndefined();
});
test('strips unknown keys', () => {
const { error, value } = schemas.backupRestoreFile.validate({
encryptionKey: 'secret',
malicious: 'yes',
}, { stripUnknown: true });
expect(error).toBeUndefined();
expect(value).not.toHaveProperty('malicious');
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* Joi-based request body validation middleware.
*
* Usage:
* const { validateBody, schemas } = require('../utilities/validate');
*
* router.post('/schedule', validateBody(schemas.backupSchedule), 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(
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'),
);
// ─── 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(),
resources: Joi.object({
memory: Joi.number().min(32).max(65536).optional(),
cpus: Joi.number().min(0.1).max(64).optional(),
}).optional(),
}).required(),
}),
/** POST /apps/:appId/restore */
appRestore: Joi.object({}).max(0),
/** 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().max(10 * 1024 * 1024).optional(),
dataDark: Joi.string().max(10 * 1024 * 1024).optional(),
dataLight: Joi.string().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 };