Skip to content

Commit 0a44454

Browse files
security(sla): sanitize agent_name inputs, cap window_hours, and bound snapshot storage
- Validate and sanitize agent_name through all SLA routes using shared sanitizeString() to strip control characters and enforce length limits - Cap window_hours to 720 (30 days) to prevent expensive full-table scans via unbounded time windows in /check endpoint - Add snapshot storage cap (10K per agent) with automatic oldest-first eviction to prevent disk exhaustion from repeated /check calls - Validate metric param in DELETE /targets to reject arbitrary strings - Import sanitizeString from shared validation module for consistency
1 parent 6bfb882 commit 0a44454

1 file changed

Lines changed: 52 additions & 9 deletions

File tree

backend/routes/sla.js

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
const express = require("express");
22
const { getDb } = require("../db");
3+
const { sanitizeString } = require("../lib/validation");
34
const { parsePagination, wrapRoute } = require("../lib/request-helpers");
45

56
const router = express.Router();
67

8+
// ── Security limits ─────────────────────────────────────────────────
9+
const MAX_AGENT_NAME_LENGTH = 128;
10+
const MAX_WINDOW_HOURS = 720; // 30 days max to prevent expensive full-table scans
11+
const MAX_SNAPSHOTS = 10000; // cap stored snapshots per agent to prevent disk exhaustion
12+
13+
// Validate agent_name: alphanumeric + common separators, sanitized.
14+
// Rejects empty/null and enforces a length cap to prevent abuse.
15+
function validateAgentName(name) {
16+
if (!name || typeof name !== "string") return null;
17+
const sanitized = sanitizeString(name, MAX_AGENT_NAME_LENGTH);
18+
if (!sanitized || sanitized.trim().length === 0) return null;
19+
return sanitized.trim();
20+
}
21+
722
// ── Schema bootstrap ────────────────────────────────────────────────
823

924
let schemaReady = false;
@@ -52,7 +67,12 @@ const VALID_METRICS = [
5267
const VALID_COMPARISONS = ["lte", "gte", "lt", "gt", "eq"];
5368

5469
function validateTarget(body) {
55-
if (!body.agent_name || typeof body.agent_name !== "string" || body.agent_name.length > 128) {
70+
const agentName = validateAgentName(body.agent_name);
71+
if (!agentName) {
72+
return "agent_name is required (string, max 128 chars, no control characters)";
73+
}
74+
body.agent_name = agentName; // normalize
75+
if (body.agent_name.length > MAX_AGENT_NAME_LENGTH) {
5676
return "agent_name is required (string, max 128 chars)";
5777
}
5878
if (!body.metric || !VALID_METRICS.includes(body.metric)) {
@@ -132,7 +152,7 @@ function computeAgentMetrics(db, agentName, windowStart, windowEnd) {
132152
router.get("/targets", wrapRoute("list SLA targets", (req, res) => {
133153
ensureSchema();
134154
const db = getDb();
135-
const { agent_name } = req.query;
155+
const agent_name = req.query.agent_name ? validateAgentName(req.query.agent_name) : null;
136156

137157
let rows;
138158
if (agent_name) {
@@ -168,10 +188,14 @@ router.put("/targets", wrapRoute("upsert SLA target", (req, res) => {
168188
router.delete("/targets", wrapRoute("delete SLA target", (req, res) => {
169189
ensureSchema();
170190
const db = getDb();
171-
const { agent_name, metric } = req.query;
191+
const agent_name = validateAgentName(req.query.agent_name);
192+
const metric = req.query.metric;
172193
if (!agent_name || !metric) {
173194
return res.status(400).json({ error: "agent_name and metric query params required" });
174195
}
196+
if (!VALID_METRICS.includes(metric)) {
197+
return res.status(400).json({ error: "metric must be one of: " + VALID_METRICS.join(", ") });
198+
}
175199

176200
const result = db.prepare("DELETE FROM sla_targets WHERE agent_name = ? AND metric = ?").run(agent_name, metric);
177201
if (result.changes === 0) return res.status(404).json({ error: "Target not found" });
@@ -183,12 +207,16 @@ router.delete("/targets", wrapRoute("delete SLA target", (req, res) => {
183207
router.post("/check", wrapRoute("check SLA compliance", (req, res) => {
184208
ensureSchema();
185209
const db = getDb();
186-
const { agent_name, window_hours } = req.body;
210+
const agent_name = validateAgentName(req.body.agent_name);
211+
const { window_hours } = req.body;
187212

188-
if (!agent_name || typeof agent_name !== "string") {
189-
return res.status(400).json({ error: "agent_name required" });
213+
if (!agent_name) {
214+
return res.status(400).json({ error: "agent_name required (non-empty string)" });
190215
}
191-
const hours = (typeof window_hours === "number" && window_hours > 0) ? window_hours : 24;
216+
const hours = Math.min(
217+
(typeof window_hours === "number" && window_hours > 0) ? window_hours : 24,
218+
MAX_WINDOW_HOURS
219+
);
192220

193221
const now = new Date();
194222
const windowEnd = now.toISOString();
@@ -229,6 +257,21 @@ router.post("/check", wrapRoute("check SLA compliance", (req, res) => {
229257
? Math.round(((targets.length - violations.length) / targets.length) * 10000) / 100
230258
: 100;
231259

260+
// Cap stored snapshots per agent to prevent unbounded disk growth.
261+
// An attacker repeatedly calling /check could otherwise fill the DB.
262+
const snapshotCount = db.prepare(
263+
"SELECT COUNT(*) as cnt FROM sla_snapshots WHERE agent_name = ?"
264+
).get(agent_name).cnt;
265+
if (snapshotCount >= MAX_SNAPSHOTS) {
266+
// Delete oldest snapshots to make room (keep most recent MAX_SNAPSHOTS - 100)
267+
db.prepare(`
268+
DELETE FROM sla_snapshots WHERE id IN (
269+
SELECT id FROM sla_snapshots WHERE agent_name = ?
270+
ORDER BY created_at ASC LIMIT 100
271+
)
272+
`).run(agent_name);
273+
}
274+
232275
db.prepare(`
233276
INSERT INTO sla_snapshots (agent_name, window_start, window_end, metrics, violations, compliance_pct)
234277
VALUES (?, ?, ?, ?, ?, ?)
@@ -250,8 +293,8 @@ router.post("/check", wrapRoute("check SLA compliance", (req, res) => {
250293
router.get("/history", wrapRoute("list SLA history", (req, res) => {
251294
ensureSchema();
252295
const db = getDb();
253-
const { agent_name } = req.query;
254-
if (!agent_name) return res.status(400).json({ error: "agent_name query param required" });
296+
const agent_name = validateAgentName(req.query.agent_name);
297+
if (!agent_name) return res.status(400).json({ error: "agent_name query param required (non-empty string)" });
255298

256299
const { limit, offset } = parsePagination(req.query, { defaultLimit: 50, maxLimit: 200 });
257300

0 commit comments

Comments
 (0)