From 1ab04be235cc0d3020d17dfb3028e4679206b886 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 1 Apr 2026 19:43:11 -0400 Subject: [PATCH] fix(security): enforce stack name validation on all routes (#314) Audit found 11 routes with no stackName validation and 2 using a weaker manual check. All 13 now use the canonical isValidStackName() guard (^[a-zA-Z0-9_-]+$), returning 400 with { error: 'Invalid stack name' }. --- CHANGELOG.md | 6 +++++ backend/src/index.ts | 46 ++++++++++++++++++++++++++++----- docs/api-reference/overview.mdx | 13 ++++++++++ docs/openapi.yaml | 6 ++++- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1db8fa..ad0c8a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* **security:** enforce `isValidStackName()` on all routes that accept a `stackName` parameter — 11 routes had no validation and 2 used a weaker manual check; all now use the canonical `^[a-zA-Z0-9_-]+$` regex guard, returning `400 Invalid stack name` on rejection + ## [0.24.1](https://github.com/AnsoCode/Sencho/compare/v0.24.0...v0.24.1) (2026-04-01) diff --git a/backend/src/index.ts b/backend/src/index.ts index 2c8845de..1a87e6d0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1095,6 +1095,10 @@ app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Requ try { const nodeId = parseInt(req.params.nodeId as string, 10); const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { res.status(404).json({ error: 'Node not found' }); @@ -2459,10 +2463,10 @@ app.get('/api/stacks/:stackName', async (req: Request, res: Response) => { app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { - if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) { - return res.status(400).json({ error: 'Invalid stack name' }); - } const { content } = req.body; console.log('PUT /api/stacks/:stackName', { stackName, contentType: typeof content, contentLength: content?.length }); if (typeof content !== 'string') { @@ -2606,10 +2610,10 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { - if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) { - return res.status(400).json({ error: 'Invalid stack name' }); - } const { content } = req.body; if (typeof content !== 'string') { return res.status(400).json({ error: 'Content must be a string' }); @@ -2661,6 +2665,9 @@ app.post('/api/stacks', async (req: Request, res: Response) => { app.delete('/api/stacks/:name', async (req: Request, res: Response) => { const stackName = req.params.name as string; if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { // Stage 1: Tell Docker to clean up ghost networks/containers try { @@ -2681,6 +2688,9 @@ app.delete('/api/stacks/:name', async (req: Request, res: Response) => { app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); res.json(containers); @@ -2756,6 +2766,9 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { const atomic = LicenseService.getInstance().getTier() === 'pro'; await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); @@ -2770,6 +2783,9 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined); res.json({ status: 'Command started' }); @@ -2781,6 +2797,9 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); @@ -2800,6 +2819,9 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) = app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); @@ -2819,6 +2841,9 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); @@ -2839,6 +2864,9 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { const atomic = LicenseService.getInstance().getTier() === 'pro'; await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic); @@ -2855,6 +2883,9 @@ app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; if (!requirePro(req, res)) return; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } try { const fsSvc = FileSystemService.getInstance(req.nodeId); const backupInfo = await fsSvc.getBackupInfo(stackName); @@ -2875,6 +2906,9 @@ app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } const fsSvc = FileSystemService.getInstance(req.nodeId); const info = await fsSvc.getBackupInfo(stackName); res.json(info); diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 33f6f449..8e44fa1c 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -73,6 +73,19 @@ The `code` field is present for specific error types: | `ADMIRAL_REQUIRED` | Endpoint requires an Admiral license | | `SCOPE_DENIED` | API token scope does not allow this operation | +## Input validation + +All endpoints that accept a `stackName` path parameter validate it against the pattern `^[a-zA-Z0-9_-]+$`. Stack names may only contain **alphanumeric characters, hyphens, and underscores**. Requests with invalid stack names are rejected immediately: + +```json +// 400 Bad Request +{ + "error": "Invalid stack name" +} +``` + +This applies to all stack endpoints: read, write, deploy, down, restart, stop, start, update, rollback, backup, delete, and container listing. + ## Rate limiting Authentication endpoints (`/api/auth/*`) are rate-limited to **5 requests per 15 minutes** in production. API token-authenticated requests are not rate-limited. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c80e04c1..f56dc0de 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -85,9 +85,13 @@ components: name: stackName in: path required: true - description: Stack directory name (URL-encoded if it contains special characters). + description: > + Stack directory name. Must match `^[a-zA-Z0-9_-]+$` (alphanumeric characters, + hyphens, and underscores only). Returns `400 Invalid stack name` if the name + contains path separators, dots, spaces, or other special characters. schema: type: string + pattern: '^[a-zA-Z0-9_-]+$' example: my-stack nodeId: name: x-node-id