[grade=B] DC-081: Input validation for 20 highest-risk mutating routes
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

Secures 20 mutating routes across 7 files against path traversal, shell
injection, and ReDoS vectors:
- containers.js: container ID validation + resource limit bounds (6 routes)
- recipes/manage.js: recipe ID slug validation (4 routes)
- tailscale.js: subdomain regex before interpolation + shell char blocking (2)
- workflows.js: workflow ID slug validation (3 routes)
- dependencies.js: service ID + dependsOn array validation (3 routes)
- logs.js: YYYY-MM-DD date format validation (1 route)
- sites.js: additional domain validation (1 route)

Uses existing REGEX patterns from constants.js. No new dependencies.
Codex: B (no blocking issues, 4 Low follow-ups for tests + strict bools).
1552/1552 tests pass, 0 regressions.
This commit is contained in:
Hermes
2026-08-12 06:20:48 -07:00
parent 37b2630525
commit 388a1fe487
7 changed files with 146 additions and 3 deletions
+20 -1
View File
@@ -1,8 +1,23 @@
const express = require('express');
const { DOCKER } = require('../../src/utilities/constants');
const { NotFoundError } = require('../../src/utilities/errors');
const { NotFoundError, ValidationError } = require('../../src/utilities/errors');
const { ok } = require('../../src/utils/responses');
/**
* Validate a recipe ID for use in Docker label filters.
* @param {string} recipeId - Recipe ID from route param
* @throws {ValidationError} if the ID contains unsafe characters
*/
function validateRecipeId(recipeId) {
if (!recipeId || typeof recipeId !== 'string') {
throw new ValidationError('Recipe ID is required');
}
// Recipe IDs are slug-style: lowercase letters, numbers, hyphens
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(recipeId)) {
throw new ValidationError('Invalid recipe ID format');
}
}
module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) {
const router = express.Router();
@@ -107,6 +122,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
*/
router.post('/:recipeId/start', asyncHandler(async (req, res) => {
const { recipeId } = req.params;
validateRecipeId(recipeId);
const containers = await findRecipeContainers(recipeId);
if (containers.length === 0) {
@@ -138,6 +154,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
*/
router.post('/:recipeId/stop', asyncHandler(async (req, res) => {
const { recipeId } = req.params;
validateRecipeId(recipeId);
const containers = await findRecipeContainers(recipeId);
if (containers.length === 0) {
@@ -170,6 +187,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
*/
router.post('/:recipeId/restart', asyncHandler(async (req, res) => {
const { recipeId } = req.params;
validateRecipeId(recipeId);
const containers = await findRecipeContainers(recipeId);
if (containers.length === 0) {
@@ -196,6 +214,7 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
*/
router.delete('/:recipeId', asyncHandler(async (req, res) => {
const { recipeId } = req.params;
validateRecipeId(recipeId);
const containers = await findRecipeContainers(recipeId);
if (containers.length === 0) {