diff --git a/packages/api/src/routes/agent-guard/index.ts b/packages/api/src/routes/agent-guard/index.ts index 3860532..cc870f5 100644 --- a/packages/api/src/routes/agent-guard/index.ts +++ b/packages/api/src/routes/agent-guard/index.ts @@ -47,8 +47,11 @@ function clampStr(v: unknown, max = MAX_STR): string | undefined { /** * POST /agent-guard/events — record a budget trip; fan out alerts on block. + * + * Requires kill_switch:trigger (owner/admin/member): an agent block IS a + * kill-switch action, so reporting one is a write that viewers shouldn't make. */ -agentGuardRouter.post("/events", requirePermission("cloud_accounts:read"), async (req, res, next) => { +agentGuardRouter.post("/events", requirePermission("kill_switch:trigger"), async (req, res, next) => { try { const guardianAccountId = (req as any).guardianAccountId as string; const orgId = guardianAccountId; diff --git a/packages/api/src/services/alerting.ts b/packages/api/src/services/alerting.ts index ced1995..03c0eaf 100644 --- a/packages/api/src/services/alerting.ts +++ b/packages/api/src/services/alerting.ts @@ -12,6 +12,26 @@ type Severity = "critical" | "error" | "warning" | "info"; const GITHUB_API_HOST = "api.github.com"; +/** + * Escape a value for safe interpolation into HTML. Alert details can carry + * user-controlled strings (e.g. a cloud account's `name` from request body, + * or a violation's serviceName), and the email channel renders them into an + * HTML body — so every interpolated string must be escaped to prevent HTML + * injection. Non-string values are stringified first. + * + * Other channels (Slack/Discord/PagerDuty/webhook) transmit via JSON and render + * values as text, so they don't need this; Slack/Discord markdown formatting + * from a crafted string is cosmetic only. + */ +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + /** * SSRF Protection: Validate webhook URLs are safe to call. * Blocks private/internal IPs and non-HTTPS URLs. @@ -186,17 +206,17 @@ async function alertEmail(channel: AlertChannel, summary: string, severity: Seve const violationRows = violations.map((v: any) => { const multiplier = v.threshold > 0 ? `${Math.round(v.currentValue / v.threshold)}×` : ""; return ` - ${v.serviceName ?? ""} - ${v.metricName ?? ""} - ${v.currentValue ?? ""} - ${v.threshold ?? ""} + ${escapeHtml(v.serviceName)} + ${escapeHtml(v.metricName)} + ${escapeHtml(v.currentValue)} + ${escapeHtml(v.threshold)} ${multiplier} `; }).join(""); const actionsTaken = (details.actionsTaken as string[] | undefined) ?? []; const actionsHtml = actionsTaken.length > 0 - ? `

Kill Switch action taken: ${actionsTaken.join(", ")}

` + ? `

Kill Switch action taken: ${actionsTaken.map(escapeHtml).join(", ")}

` : ""; const html = ` @@ -212,11 +232,11 @@ async function alertEmail(channel: AlertChannel, summary: string, severity: Seve

-

${summary}

+

${escapeHtml(summary)}

- Provider: ${String(details.provider ?? "")} · - Account: ${String(details.accountName ?? "")} · - Severity: ${severity} + Provider: ${escapeHtml(details.provider)} · + Account: ${escapeHtml(details.accountName)} · + Severity: ${escapeHtml(severity)}

diff --git a/packages/api/tests/services/alerting.test.ts b/packages/api/tests/services/alerting.test.ts index 6f8977c..7ade0c6 100644 --- a/packages/api/tests/services/alerting.test.ts +++ b/packages/api/tests/services/alerting.test.ts @@ -440,6 +440,23 @@ describe("Alerting Service", () => { expect(payload.text).toBeDefined(); }); + it("escapes HTML in user-controlled fields (accountName, serviceName, summary)", async () => { + // accountName traces to req.body.name at cloud-account creation — user-controlled. + const xss = ``; + await sendAlerts([emailChannel()], `summary ${xss}`, "critical", { + provider: "cloudflare", + accountName: `acct ${xss}`, + violations: [{ serviceName: `svc ${xss}`, metricName: "CPU", currentValue: 1, threshold: 0, severity: "critical" }], + actionsTaken: [`disconnect ${xss}`], + }); + + const [payload] = mockResendSend.mock.calls[0]; + // The raw