diff --git a/backend/src/routes/apiTokens.ts b/backend/src/routes/apiTokens.ts index 224d949f..b1360b93 100644 --- a/backend/src/routes/apiTokens.ts +++ b/backend/src/routes/apiTokens.ts @@ -6,6 +6,7 @@ import { authMiddleware } from '../middleware/auth'; import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { isDebugEnabled } from '../utils/debug'; +import { parseIntParam } from '../utils/parseIntParam'; // JWT ceiling exceeds the longest user-selectable expiry (365d) so the DB // check (expires_at) is always the tighter bound. @@ -109,8 +110,8 @@ apiTokensRouter.delete('/:id', authMiddleware, async (req: Request, res: Respons if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid token ID.' }); return; } + const id = parseIntParam(req, res, 'id', 'token ID'); + if (id === null) return; const apiToken = DatabaseService.getInstance().getApiTokenById(id); if (!apiToken) { res.status(404).json({ error: 'API token not found.' }); return; } diff --git a/backend/src/routes/autoHeal.ts b/backend/src/routes/autoHeal.ts index 05aeed6d..d86fcee2 100644 --- a/backend/src/routes/autoHeal.ts +++ b/backend/src/routes/autoHeal.ts @@ -4,6 +4,7 @@ import { DatabaseService } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin } from '../middleware/tierGates'; import { getErrorMessage } from '../utils/errors'; +import { parseIntParam } from '../utils/parseIntParam'; const AutoHealPolicyCreateSchema = z.object({ stack_name: z.string().min(1).max(255), @@ -15,15 +16,6 @@ const AutoHealPolicyCreateSchema = z.object({ }); const AutoHealPolicyUpdateSchema = AutoHealPolicyCreateSchema.partial().omit({ stack_name: true }); -function parsePolicyId(req: Request, res: Response): number | null { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { - res.status(400).json({ error: 'Invalid id' }); - return null; - } - return id; -} - export const autoHealRouter = Router(); autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => { @@ -71,7 +63,7 @@ autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response): autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; - const id = parsePolicyId(req, res); + const id = parseIntParam(req, res, 'id'); if (id === null) return; const parsed = AutoHealPolicyUpdateSchema.safeParse(req.body); if (!parsed.success) { @@ -92,7 +84,7 @@ autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Respon autoHealRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; - const id = parsePolicyId(req, res); + const id = parseIntParam(req, res, 'id'); if (id === null) return; try { const db = DatabaseService.getInstance(); @@ -107,7 +99,7 @@ autoHealRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Respo autoHealRouter.get('/policies/:id/history', authMiddleware, (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; - const id = parsePolicyId(req, res); + const id = parseIntParam(req, res, 'id'); if (id === null) return; const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 100); try { diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 06cb1845..b11640d9 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -20,6 +20,7 @@ import { getLatestVersion } from '../utils/version-check'; import { isValidStackName } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; +import { parseIntParam } from '../utils/parseIntParam'; import { POLICY_SEVERITIES } from '../utils/severity'; import { CloudBackupService } from '../services/CloudBackupService'; import { NotificationService } from '../services/NotificationService'; @@ -28,22 +29,6 @@ import { LicenseService } from '../services/LicenseService'; const updateTracker = FleetUpdateTrackerService.getInstance(); const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - -/** - * Parse a numeric route param. Writes a 400 response and returns null when - * the value isn't a valid integer; callers early-return on null. Collapses - * the 7 copies of the `parseInt ... isNaN ... 400 'Invalid X ID'` shape - * across the fleet router. - */ -function parseIdParam(req: Request, res: Response, paramName: string, label: string): number | null { - const raw = req.params[paramName] as string | undefined; - const parsed = parseInt(raw ?? '', 10); - if (isNaN(parsed)) { - res.status(400).json({ error: `Invalid ${label}` }); - return null; - } - return parsed; -} const UPDATE_TIMEOUT_MSG = 'Node did not come back online within 5 minutes.'; const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure @@ -399,7 +384,7 @@ fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res if (!requirePaid(req, res)) return; try { - const nodeId = parseIdParam(req, res, 'nodeId', 'node ID'); + const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); if (nodeId === null) return; const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { @@ -439,7 +424,7 @@ fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, as if (!requirePaid(req, res)) return; try { - const nodeId = parseIdParam(req, res, 'nodeId', 'node ID'); + const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); if (nodeId === null) return; const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { @@ -633,7 +618,7 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { - const nodeId = parseIdParam(req, res, 'nodeId', 'node ID'); + const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); if (nodeId === null) return; const db = DatabaseService.getInstance(); const node = db.getNode(nodeId); @@ -786,7 +771,7 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon fleetRouter.delete('/nodes/:nodeId/update-status', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { - const nodeId = parseIdParam(req, res, 'nodeId', 'node ID'); + const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); if (nodeId === null) return; const node = DatabaseService.getInstance().getNode(nodeId); if (!node) { @@ -934,7 +919,7 @@ fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Resp if (!requirePaid(req, res)) return; try { - const id = parseIdParam(req, res, 'id', 'snapshot ID'); + const id = parseIntParam(req, res, 'id', 'snapshot ID'); if (id === null) return; const db = DatabaseService.getInstance(); const snapshot = db.getSnapshot(id); @@ -980,7 +965,7 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, if (!requirePaid(req, res)) return; try { - const snapshotId = parseIdParam(req, res, 'id', 'snapshot ID'); + const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID'); if (snapshotId === null) return; const { nodeId, stackName, redeploy = false } = req.body; @@ -1093,7 +1078,7 @@ fleetRouter.delete('/snapshots/:id', authMiddleware, async (req: Request, res: R if (!requirePaid(req, res)) return; try { - const id = parseIdParam(req, res, 'id', 'snapshot ID'); + const id = parseIntParam(req, res, 'id', 'snapshot ID'); if (id === null) return; const db = DatabaseService.getInstance(); const snapshot = db.getSnapshot(id); diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index bbd6dcfb..7bac8a29 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -12,6 +12,7 @@ import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from '../helpers/constants'; import { isValidStackName } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; +import { parseIntParam } from '../utils/parseIntParam'; const activeBulkActions = new Set(); @@ -105,8 +106,8 @@ labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pr if (!requirePaid(req, res)) return; if (!requireBody(req, res)) return; try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; } + const id = parseIntParam(req, res, 'id', 'label ID'); + if (id === null) return; const nodeId = req.nodeId ?? 0; const { name, color } = req.body; @@ -148,8 +149,8 @@ labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pr labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; } + const id = parseIntParam(req, res, 'id', 'label ID'); + if (id === null) return; const nodeId = req.nodeId ?? 0; if (isDebugEnabled()) console.debug('[Labels:debug] Delete label:', { id, nodeId }); DatabaseService.getInstance().deleteLabel(id, nodeId); @@ -165,8 +166,8 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; } + const id = parseIntParam(req, res, 'id', 'label ID'); + if (id === null) return; const { action } = req.body; const validActions = ['deploy', 'stop', 'restart']; if (!action || !validActions.includes(action)) { diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index d62469d5..f6543688 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -12,18 +12,10 @@ import { } from '../helpers/notificationChannels'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; +import { parseIntParam } from '../utils/parseIntParam'; const VALID_CATEGORIES: ReadonlySet = new Set(ALL_NOTIFICATION_CATEGORIES); -function parseRouteId(req: Request, res: Response): number | null { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { - res.status(400).json({ error: 'Invalid route ID' }); - return null; - } - return id; -} - function validateNodeId(nodeId: unknown, res: Response): number | null | false { if (nodeId === undefined || nodeId === null) return null; if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) { @@ -81,8 +73,8 @@ notificationsRouter.post('/read', authMiddleware, async (req: Request, res: Resp notificationsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise => { try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid notification ID' }); return; } + const id = parseIntParam(req, res, 'id', 'notification ID'); + if (id === null) return; const nodeId = req.nodeId ?? 0; DatabaseService.getInstance().deleteNotification(nodeId, id); res.json({ success: true }); @@ -193,7 +185,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const id = parseRouteId(req, res); + const id = parseIntParam(req, res, 'id', 'route ID'); if (id === null) return; const existing = DatabaseService.getInstance().getNotificationRoute(id); @@ -269,7 +261,7 @@ notificationRoutesRouter.delete('/:id', authMiddleware, (req: Request, res: Resp if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const id = parseRouteId(req, res); + const id = parseIntParam(req, res, 'id', 'route ID'); if (id === null) return; const changes = DatabaseService.getInstance().deleteNotificationRoute(id); @@ -286,7 +278,7 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request, if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const id = parseRouteId(req, res); + const id = parseIntParam(req, res, 'id', 'route ID'); if (id === null) return; const route = DatabaseService.getInstance().getNotificationRoute(id); diff --git a/backend/src/routes/registries.ts b/backend/src/routes/registries.ts index b3c09930..5e3efa50 100644 --- a/backend/src/routes/registries.ts +++ b/backend/src/routes/registries.ts @@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { RegistryService } from '../services/RegistryService'; import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; +import { parseIntParam } from '../utils/parseIntParam'; const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const; const REGISTRY_SCOPE_MESSAGE = 'API tokens cannot manage registry credentials.'; @@ -80,8 +81,8 @@ registriesRouter.put('/:id', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + const id = parseIntParam(req, res, 'id', 'registry ID'); + if (id === null) return; const existing = RegistryService.getInstance().getById(id); if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } @@ -118,8 +119,8 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + const id = parseIntParam(req, res, 'id', 'registry ID'); + if (id === null) return; const existing = RegistryService.getInstance().getById(id); if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } @@ -137,8 +138,8 @@ registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise< if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + const id = parseIntParam(req, res, 'id', 'registry ID'); + if (id === null) return; const result = await RegistryService.getInstance().testConnection(id); res.json(result); diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index 1566860e..7c600d89 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -6,6 +6,7 @@ import { SchedulerService } from '../services/SchedulerService'; import { requirePaid, requireAdmin, requireScheduledTaskTier } from '../middleware/tierGates'; import { escapeCsvField } from '../utils/csv'; import { getErrorMessage } from '../utils/errors'; +import { parseIntParam } from '../utils/parseIntParam'; const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const; const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start'] as const; @@ -17,15 +18,6 @@ type ScheduledAction = typeof VALID_ACTIONS[number]; const STACK_ONLY_ACTIONS = new Set(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']); -function parseTaskId(req: Request, res: Response): number | null { - const id = parseInt(req.params.id as string, 10); - if (isNaN(id)) { - res.status(400).json({ error: 'Invalid task ID' }); - return null; - } - return id; -} - /** * Validate that the target_type is compatible with the action. Each action * has exactly one allowed target_type; the helper returns an error message @@ -190,7 +182,7 @@ scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { - const id = parseTaskId(req, res); + const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const task = DatabaseService.getInstance().getScheduledTask(id); if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } @@ -206,7 +198,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { - const id = parseTaskId(req, res); + const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const db = DatabaseService.getInstance(); @@ -286,7 +278,7 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { - const id = parseTaskId(req, res); + const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const db = DatabaseService.getInstance(); @@ -307,7 +299,7 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { - const id = parseTaskId(req, res); + const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const db = DatabaseService.getInstance(); @@ -337,7 +329,7 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { - const id = parseTaskId(req, res); + const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const db = DatabaseService.getInstance(); @@ -368,7 +360,7 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { - const id = parseTaskId(req, res); + const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const db = DatabaseService.getInstance(); @@ -404,7 +396,7 @@ scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { - const id = parseTaskId(req, res); + const id = parseIntParam(req, res, 'id', 'task ID'); if (id === null) return; const db = DatabaseService.getInstance(); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index a5999b7c..5e9278d6 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -8,6 +8,7 @@ import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { BCRYPT_SALT_ROUNDS, MIN_PASSWORD_LENGTH } from '../helpers/constants'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; +import { parseIntParam } from '../utils/parseIntParam'; const USERS_SCOPE_MESSAGE = 'API tokens cannot access user management.'; const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor']; @@ -218,11 +219,8 @@ usersRouter.post('/:id/mfa/reset', authMiddleware, (req: Request, res: Response) if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; try { - const id = parseInt(req.params.id as string, 10); - if (!Number.isFinite(id)) { - res.status(400).json({ error: 'Invalid user id' }); - return; - } + const id = parseIntParam(req, res, 'id', 'user id'); + if (id === null) return; const db = DatabaseService.getInstance(); const target = db.getUser(id); if (!target) { diff --git a/backend/src/utils/parseIntParam.ts b/backend/src/utils/parseIntParam.ts new file mode 100644 index 00000000..ec90062a --- /dev/null +++ b/backend/src/utils/parseIntParam.ts @@ -0,0 +1,24 @@ +import type { Request, Response } from 'express'; + +/** + * Parse a numeric route param. Writes a 400 response and returns null when + * the value isn't a valid integer; callers early-return on null. + * + * @param paramName the key in req.params (e.g. 'id', 'nodeId') + * @param label optional human-readable label used in the error body. + * Falls back to paramName when omitted (e.g. 'Invalid id'). + */ +export function parseIntParam( + req: Request, + res: Response, + paramName: string, + label?: string, +): number | null { + const raw = req.params[paramName] as string | undefined; + const parsed = parseInt(raw ?? '', 10); + if (isNaN(parsed)) { + res.status(400).json({ error: `Invalid ${label ?? paramName}` }); + return null; + } + return parsed; +}