diff --git a/src/review/loop-escalation-wire.ts b/src/review/loop-escalation-wire.ts index b557012c6d..4747153481 100644 --- a/src/review/loop-escalation-wire.ts +++ b/src/review/loop-escalation-wire.ts @@ -28,7 +28,9 @@ import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest"; import { errorMessage } from "../utils/json"; -const ALLOWED_DISCORD_HOSTS = new Set(["discord.com", "discordapp.com"]); +// Byte-faithful with src/review/alerts.ts and src/services/notify-discord.ts (#9288): the two extra +// subdomains (canary/ptb) are valid Discord webhook hosts the sibling validators already accept. +const ALLOWED_DISCORD_HOSTS = new Set(["discord.com", "discordapp.com", "canary.discord.com", "ptb.discord.com"]); const DEFAULT_COOLDOWN_MINUTES = 60; const AUDIT_EVENT_TYPE = "loop_escalation_notification.discord"; const AUDIT_TARGET_KEY = "fleet:loop-escalation"; @@ -88,7 +90,7 @@ function envString(env: Env, name: string): string | undefined { return typeof fromEnv === "string" && fromEnv.trim().length > 0 ? fromEnv.trim() : undefined; } -function isValidDiscordWebhook(url: string): boolean { +export function isValidDiscordWebhook(url: string): boolean { try { const parsed = new URL(url); if (parsed.protocol !== "https:") return false; diff --git a/test/unit/loop-escalation-wire.test.ts b/test/unit/loop-escalation-wire.test.ts index a03a6a70d3..089aefa10b 100644 --- a/test/unit/loop-escalation-wire.test.ts +++ b/test/unit/loop-escalation-wire.test.ts @@ -3,6 +3,7 @@ import * as repositories from "../../src/db/repositories"; import { clearLoopEscalationManifestOverrideCacheForTest, isLoopEscalationSweepEnabled, + isValidDiscordWebhook, loadActiveLoopsFromEnv, parseActiveLoopFacts, resolveLoopEscalationManifestOverride, @@ -13,6 +14,24 @@ import { createTestEnv } from "../helpers/d1"; const SELF_REPO = "JSONbored/loopover"; +describe("isValidDiscordWebhook host allowlist (#9288)", () => { + it("accepts all four Discord webhook hosts its sibling validators accept — including canary/ptb", () => { + for (const host of ["discord.com", "discordapp.com", "canary.discord.com", "ptb.discord.com"]) { + // canary/ptb were rejected before the fix, dropping a valid webhook as invalid_global_webhook. + expect(isValidDiscordWebhook(`https://${host}/api/webhooks/123/abc`)).toBe(true); + expect(isValidDiscordWebhook(`https://${host.toUpperCase()}/api/webhooks/123/abc`)).toBe(true); // host match is case-insensitive + } + }); + + it("still rejects non-Discord hosts, non-https, and non-webhook paths", () => { + expect(isValidDiscordWebhook("https://evil.example.com/api/webhooks/123/abc")).toBe(false); + expect(isValidDiscordWebhook("https://notdiscord.com/api/webhooks/123/abc")).toBe(false); + expect(isValidDiscordWebhook("http://discord.com/api/webhooks/123/abc")).toBe(false); // not https + expect(isValidDiscordWebhook("https://discord.com/not/a/webhook")).toBe(false); // wrong path + expect(isValidDiscordWebhook("not a url")).toBe(false); + }); +}); + describe("isLoopEscalationSweepEnabled (#6349)", () => { it("defaults OFF and accepts the standard truthy env forms", () => { for (const off of [undefined, "", "false", "no", "0", "off"]) {