diff --git a/apps/api/src/lib/alerts.ts b/apps/api/src/lib/alerts.ts index f92162d..93dab01 100644 --- a/apps/api/src/lib/alerts.ts +++ b/apps/api/src/lib/alerts.ts @@ -40,6 +40,15 @@ const SEND_TIMEOUT_MS = 30_000; /** Failed port-22 checks in a row before a host counts as offline. */ const DOWN_AFTER = 2; +// One text goes to every service: lookalikes and zero-width spaces stop Slack ``/``, `@channel` and markdown links without showing escapes in plain-text ones. +const defuse = (s: string) => + s + .replaceAll("<", "‹") + .replaceAll(">", "›") + .replace(/(?!\n)\p{Cc}/gu, " ") + .replace(/(? Object.fromEntries( Object.entries(process.env).filter( @@ -213,7 +222,12 @@ export function alerter({ .get(String(id)); const hostName = (e: AuditInput) => (e.name as string | undefined) ?? host(e.host)?.name ?? String(e.host); - const who = (e: AuditInput) => String(e.email ?? e.sub ?? "someone"); + // User-set text (task name, IdP-editable email): a newline could forge alert lines, a URL would autolink. + const userText = (s: unknown) => + String(s) + .replace(/[\n\u2028\u2029]/g, " ") + .replaceAll("://", ":/\u200b/"); + const who = (e: AuditInput) => userText(e.email ?? e.sub ?? "someone"); function securityMessage(e: AuditInput): Message { const lines = [ @@ -230,7 +244,7 @@ export function alerter({ ), ); if (e.event === "session_kill" || e.event === "user_signout") - lines.push(`User: ${e.user}`); + lines.push(`User: ${userText(e.user)}`); lines.push(new URL("/admin/audit", appUrl).href); return { title: `CertShell: ${SECURITY_TITLES[e.event]?.(e, hostName(e))}`, @@ -262,9 +276,9 @@ export function alerter({ >("SELECT name, email, sub FROM runs WHERE id = ?") .get(runId); return { - title: `CertShell: task "${run?.name}" failed on ${failed.length} of ${hosts.length} host${hosts.length === 1 ? "" : "s"}`, + title: `CertShell: task "${userText(run?.name)}" failed on ${failed.length} of ${hosts.length} host${hosts.length === 1 ? "" : "s"}`, body: [ - `Run by ${run?.email ?? run?.sub}.`, + `Run by ${who({ event: "", email: run?.email, sub: run?.sub })}.`, ...failed.map( (h) => `${h.login}@${h.host_name}: ${h.exit_code === null ? "didn't finish" : `exit ${h.exit_code}`}`, @@ -314,6 +328,7 @@ export function alerter({ }; } else return; if (!msg) return; + msg = { title: defuse(msg.title), body: defuse(msg.body) }; for (const row of rows()) { const c = channelOf(row); if (c.events.includes(event) && matches(c)) void send(row, msg); diff --git a/apps/api/test/alerts.test.ts b/apps/api/test/alerts.test.ts index 6156d54..bcbbd7a 100644 --- a/apps/api/test/alerts.test.ts +++ b/apps/api/test/alerts.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import type { AlertChannel, AlertInput } from "@repo/shared"; import { alerter } from "../src/lib/alerts"; import { audit } from "../src/lib/audit"; +import { createSession } from "../src/lib/sessions"; +import { SESSION_COOKIE } from "../src/routes/auth"; import { hostKeyRejected } from "../src/ssh/terminal"; import { APP_URL, startApp } from "./helpers/app"; @@ -48,6 +50,19 @@ const call = (method: string, path: string, body?: unknown) => body: body === undefined ? undefined : JSON.stringify(body), }); +const post = async (path: string, body: unknown, cookie = ctx.cookie) => + (await ( + await ctx.app.request(`${APP_URL}/api/tasks${path}`, { + method: "POST", + headers: { + cookie, + origin: APP_URL, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }) + ).json()) as { id: number | string }; + const input = (over: Partial = {}): AlertInput => ({ name: "ops", url: "ntfy://ntfy.sh/secret-topic", @@ -307,6 +322,21 @@ test("terminal event: filtered by host tag or login; empty filter = every termin ]); }); +test("terminal event: mentions and links in the email arrive defused", async () => { + await setup(); + await create({ events: ["terminal"] }); + audit(ctx.db, { + event: "session_start", + sub: "u", + email: " _@channel [x](https://evil.test)\nfake\u0007line", + principal: "ws:h1:root", + session: "s1", + }); + expect((await sent(1))[0]?.args[2]).toBe( + "CertShell: ‹!here› _@\u200bchannel [x]\u200b(https:/\u200b/evil.test) fake line opened root@h1", + ); +}); + test("task_failed: one alert when the last host finishes, only if one failed", async () => { await setup({ command: ({ host, remote }) => ["sh", "-c", remote ?? "", host.id], @@ -315,32 +345,19 @@ test("task_failed: one alert when the last host finishes, only if one failed", a root: "certshell-admins", }); await create({ events: ["task_failed"] }); - const task = async (script: string) => { - const r = await ctx.app.request(`${APP_URL}/api/tasks`, { - method: "POST", - headers: { - cookie: ctx.cookie, - origin: APP_URL, - "content-type": "application/json", - }, - body: JSON.stringify({ + const task = async (script: string) => + ( + await post("", { name: "Upgrade", script, targets: [ { host: "h1", login: "root" }, { host: "h2", login: "root" }, ], - }), - }); - return ((await r.json()) as { id: number }).id; - }; - const run = async (id: number) => { - const r = await ctx.app.request(`${APP_URL}/api/tasks/${id}/run`, { - method: "POST", - headers: { cookie: ctx.cookie, origin: APP_URL }, - }); - return ((await r.json()) as { id: string }).id; - }; + }) + ).id; + const run = async (id: number | string) => + String((await post(`/${id}/run`, {})).id); const done = async (runId: string) => { for (let i = 0; i < 100; i++) { @@ -367,6 +384,34 @@ test("task_failed: one alert when the last host finishes, only if one failed", a ); }); +test("task_failed: mentions and links in the task name and email arrive defused", async () => { + await setup({ command: () => ["sh", "-c", "exit 1"] }); + await create({ events: ["task_failed"] }); + const cookie = `${SESSION_COOKIE}=${createSession(ctx.db, { + iss: "https://id.test", + sub: "user-2", + email: "\n_@here\u2028https://evil.test", + groups: ["certshell-admins"], + })}`; + const { id } = await post( + "", + { + name: " https://evil.test/login", + script: "true", + targets: [{ host: "h1", login: "root" }], + }, + cookie, + ); + const runId = (await post(`/${id}/run`, {}, cookie)).id; + const [s] = await sent(1); + expect(s?.args[2]).toBe( + 'CertShell: task "‹!channel› ‹https:/\u200b/evil.test|x› https:/\u200b/evil.test/login" failed on 1 of 1 host', + ); + expect(s?.body).toBe( + `Run by ‹!channel› _@\u200bhere https:/\u200b/evil.test.\nroot@h1: exit 1\nhttps://ssh.test/tasks/runs/${runId}`, + ); +}); + test("reboot: alerted when host facts first show the flag, not again until it clears", async () => { let reboot = "1"; await setup({ command: () => ["sh", "-c", `echo reboot=${reboot}`] });