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
+171 -4
View File
@@ -140,6 +140,34 @@ describe('schemas.appDeploy', () => {
expect(error).toBeDefined();
});
test('rejects malformed IPv6 CIDR (regression: hex/colon regex was permissive)', () => {
// Old regex /^[0-9a-fA-F:]+\/(\d{1,3})$/ accepted these; Joi's authoritative
// CIDR validator must reject them.
const { error: e1 } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', allowedIPs: ['::::/64'] },
});
expect(e1).toBeDefined();
const { error: e2 } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', allowedIPs: ['zzzz:::/64'] },
});
expect(e2).toBeDefined();
const { error: e3 } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', allowedIPs: ['not-an-ip'] },
});
expect(e3).toBeDefined();
});
test('accepts valid IPv6 CIDR', () => {
const { error } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', allowedIPs: ['2001:db8::/32'] },
});
expect(error).toBeUndefined();
});
test('accepts valid customVolumes objects', () => {
const { error } = schemas.appDeploy.validate({
appId: 'plex',
@@ -151,13 +179,37 @@ describe('schemas.appDeploy', () => {
expect(error).toBeUndefined();
});
test('strips unknown keys in config', () => {
test('preserves unknown template-specific config fields (forward-compat)', () => {
// appDeploy.config uses .unknown(true) so future template-specific fields
// (e.g. a new app that posts `apiKey`, `databaseType`, ...) survive validation.
const { error, value } = schemas.appDeploy.validate({
appId: 'plex',
config: { subdomain: 'plex', isAdmin: true },
}, { stripUnknown: true });
config: {
subdomain: 'plex',
aFutureTemplateField: 'xyz',
apiKey: 'secret',
},
});
expect(error).toBeUndefined();
expect(value.config).not.toHaveProperty('isAdmin');
expect(value.config).toHaveProperty('aFutureTemplateField', 'xyz');
expect(value.config).toHaveProperty('apiKey', 'secret');
});
test('preserves template-specific config fields (sslType, dnsType, plexClaimToken)', () => {
// The frontend posts these — they must survive validation or deployments break.
const { error, value } = schemas.appDeploy.validate({
appId: 'plex',
config: {
subdomain: 'plex',
sslType: 'self-signed',
dnsType: 'private',
plexClaimToken: 'claim-abc-123',
},
});
expect(error).toBeUndefined();
expect(value.config).toHaveProperty('sslType', 'self-signed');
expect(value.config).toHaveProperty('dnsType', 'private');
expect(value.config).toHaveProperty('plexClaimToken', 'claim-abc-123');
});
});
@@ -201,3 +253,118 @@ describe('schemas.backupRestoreFile', () => {
expect(value).not.toHaveProperty('malicious');
});
});
describe('schemas.backupRestore', () => {
test('accepts empty body (all fields optional)', () => {
const { error, value } = schemas.backupRestore.validate({});
expect(error).toBeUndefined();
expect(value).toEqual({});
});
test('accepts known control flags', () => {
const { error } = schemas.backupRestore.validate({
encryptionKey: 'secret',
restartContainers: true,
services: true,
config: true,
credentials: true,
volumes: true,
});
expect(error).toBeUndefined();
});
test('strips unknown keys', () => {
const { error, value } = schemas.backupRestore.validate({
services: true,
shellCommand: 'rm -rf /',
}, { stripUnknown: true });
expect(error).toBeUndefined();
expect(value).not.toHaveProperty('shellCommand');
});
});
describe('schemas.appRestore', () => {
test('accepts empty body via middleware', () => {
const req = mockReq({});
const next = jest.fn();
validateBody(schemas.appRestore)(req, {}, next);
expect(next).toHaveBeenCalled();
});
test('rejects body with any key via middleware', () => {
const req = mockReq({ filename: 'backup.tar' });
expect(() => validateBody(schemas.appRestore)(req, {}, jest.fn())).toThrow(/empty/);
});
test('rejects non-object body via middleware', () => {
const req = mockReq('just a string');
expect(() => validateBody(schemas.appRestore)(req, {}, jest.fn())).toThrow(/empty/);
});
});
describe('schemas.appRevert', () => {
test('accepts empty body', () => {
const { error } = schemas.appRevert.validate({});
expect(error).toBeUndefined();
});
test('accepts optional encryption key + restart flag', () => {
const { error } = schemas.appRevert.validate({
encryptionKey: 'secret',
restartContainers: true,
});
expect(error).toBeUndefined();
});
test('strips unknown keys (no shell injection vector)', () => {
const { error, value } = schemas.appRevert.validate({
encryptionKey: 'secret',
path: '/etc/passwd',
shellCommand: 'rm -rf /',
}, { stripUnknown: true });
expect(error).toBeUndefined();
expect(value).not.toHaveProperty('path');
expect(value).not.toHaveProperty('shellCommand');
});
});
describe('schemas.logoUpload', () => {
test('requires at least one field', () => {
const { error } = schemas.logoUpload.validate({});
expect(error).toBeDefined();
});
test('accepts single data field', () => {
const { error } = schemas.logoUpload.validate({ data: 'data:image/png;base64,abc' });
expect(error).toBeUndefined();
});
test('accepts dataDark + dataLight pair', () => {
const { error } = schemas.logoUpload.validate({
dataDark: 'data:image/png;base64,dark',
dataLight: 'data:image/png;base64,light',
});
expect(error).toBeUndefined();
});
test('accepts position enum', () => {
const { error } = schemas.logoUpload.validate({
data: 'data:image/png;base64,abc',
position: 'center',
});
expect(error).toBeUndefined();
});
test('rejects invalid position', () => {
const { error } = schemas.logoUpload.validate({
data: 'data:image/png;base64,abc',
position: 'diagonal',
});
expect(error).toBeDefined();
});
test('rejects empty-string data fields', () => {
const { error } = schemas.logoUpload.validate({ data: '' });
expect(error).toBeDefined();
});
});