Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/api/src/routes/agent-guard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 29 additions & 9 deletions packages/api/src/services/alerting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

/**
* SSRF Protection: Validate webhook URLs are safe to call.
* Blocks private/internal IPs and non-HTTPS URLs.
Expand Down Expand Up @@ -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 `<tr>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;">${v.serviceName ?? ""}</td>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;">${v.metricName ?? ""}</td>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;color:#ff6b6b;">${v.currentValue ?? ""}</td>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;">${v.threshold ?? ""}</td>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;">${escapeHtml(v.serviceName)}</td>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;">${escapeHtml(v.metricName)}</td>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;color:#ff6b6b;">${escapeHtml(v.currentValue)}</td>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;">${escapeHtml(v.threshold)}</td>
<td style="padding:6px 12px;border-bottom:1px solid #2a2f4a;font-weight:bold;color:#ff6b6b;">${multiplier}</td>
</tr>`;
}).join("");

const actionsTaken = (details.actionsTaken as string[] | undefined) ?? [];
const actionsHtml = actionsTaken.length > 0
? `<p style="color:#4ade80;"><strong>Kill Switch action taken:</strong> ${actionsTaken.join(", ")}</p>`
? `<p style="color:#4ade80;"><strong>Kill Switch action taken:</strong> ${actionsTaken.map(escapeHtml).join(", ")}</p>`
: "";

const html = `<!DOCTYPE html>
Expand All @@ -212,11 +232,11 @@ async function alertEmail(channel: AlertChannel, summary: string, severity: Seve
</p>

<div style="background:rgba(255,107,107,0.08);border:1px solid rgba(255,107,107,0.2);border-radius:8px;padding:16px;margin-bottom:24px;">
<p style="color:#fff;font-size:15px;font-weight:600;margin:0 0 4px;">${summary}</p>
<p style="color:#fff;font-size:15px;font-weight:600;margin:0 0 4px;">${escapeHtml(summary)}</p>
<p style="color:#9ca3af;font-size:13px;margin:0;">
Provider: <strong style="color:#fff;">${String(details.provider ?? "")}</strong> ·
Account: <strong style="color:#fff;">${String(details.accountName ?? "")}</strong> ·
Severity: <strong style="color:#ff6b6b;">${severity}</strong>
Provider: <strong style="color:#fff;">${escapeHtml(details.provider)}</strong> ·
Account: <strong style="color:#fff;">${escapeHtml(details.accountName)}</strong> ·
Severity: <strong style="color:#ff6b6b;">${escapeHtml(severity)}</strong>
</p>
</div>

Expand Down
17 changes: 17 additions & 0 deletions packages/api/tests/services/alerting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<script>alert('x')</script>`;
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 <script> tag must never appear in the rendered HTML…
expect(payload.html).not.toContain("<script>");
// …but the escaped form should, proving the content is preserved, just neutralized.
expect(payload.html).toContain("&lt;script&gt;");
});

it("subject includes severity emoji and uppercased severity", async () => {
await sendAlerts([emailChannel()], "Cost runaway", "critical", {
provider: "cloudflare", accountName: "zombay-cf", violations,
Expand Down