import { env } from "cloudflare:workers"; import { all, first, run } from "./db"; import { captureServerEvent } from "./analytics"; import { hmacSha256Hex, randomId } from "./crypto"; import { badRequest, notFoundError } from "./errors"; import { cookieSecret, publishMeta, SCRATCH_LIMITS } from "./sites/config"; import { loadSiteById } from "./sites/deploy"; import { resolveAbuseTarget } from "./abuse-targets"; import type { AbuseTarget } from "./abuse-targets"; import { recordAdminAction } from "./admin-audit"; import { isHideable, setModerationHidden } from "./moderation"; import { notifyAdmins } from "./account-disable-actions"; import { ABUSE_TARGET_LABELS } from "~/lib/abuse"; import type { AbuseAction, AbuseCategory, AbuseStatus, AbuseTargetKind } from "~/lib/abuse"; export { siteIdForUrl } from "./abuse-targets"; export type AbuseSource = "web" | "email" | "site"; /** * How much one reporter counts toward auto-pause. Signed-in users and mail whose sender domain passed DMARC are * accountable; an IP is cheap to rotate; unauthenticated mail can forge any sender, so it never pauses anything. */ export type ReporterKind = "user" | "ip" | "email-verified" | "email-unverified"; const REPORTER_WEIGHT: Record = { user: 2, "email-verified": 2, ip: 1, "email-unverified": 0 }; /** Phishing and malware hurt visitors immediately, so fewer distinct reporters are enough to hold the site. */ const SEVERE: ReadonlySet = new Set(["phishing", "malware"]); /** * Weighted reporter totals that pause a site (an IP weighs 1, an accountable reporter 2). Scratch sites are * anonymous and short-lived, so a few reports hold them. Owned sites count accountable reporters only and need * many of them; below that an admin decides. */ const PAUSE_THRESHOLDS = { scratch: { severe: 2, normal: SCRATCH_LIMITS.reportsToDisable, minWeight: 1 }, owned: { severe: 8, normal: 20, minWeight: 2 }, } as const; const PAUSE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; export const PAUSE_REASON = "Paused pending abuse review"; const MAX_DETAILS = 4000; const MAX_URL = 2000; export type AbuseReportInput = { source: AbuseSource; targetUrl: string; category: AbuseCategory; details: string; reporterEmail: string | null; /** Raw IP, account id or sender domain; only its HMAC is stored. */ reporterKey: string; reporterKind: ReporterKind; /** Known when the report came from the site itself, so the URL need not be resolved. */ siteId?: string; }; export type AbuseReportRow = { id: string; source: AbuseSource; target_url: string; site_id: string | null; target_kind: AbuseTargetKind | null; target_id: string | null; category: string; details: string; reporter_email: string | null; reporter_weight: number; status: AbuseStatus; resolution: string | null; resolved_by: string | null; resolved_at: number | null; created_at: number; }; const CATEGORY_HINTS: [AbuseCategory, RegExp][] = [ ["phishing", /phish|credential|password|login page|impersonat|scam|fraud/i], ["malware", /malware|virus|trojan|ransom|miner|malicious (file|script|download)/i], ["copyright", /copyright|dmca|infring|trademark/i], ["spam", /\bspam|seo|unsolicited/i], ["harassment", /harass|threat|doxx|abus(e|ive) (toward|against)/i], ["illegal", /csam|child|illegal|terror/i], ]; /** Best-effort category for free-text reports (site endpoint, email); admins can still read the details. */ export function guessAbuseCategory(text: string): AbuseCategory { return CATEGORY_HINTS.find(([, pattern]) => pattern.test(text))?.[0] ?? "other"; } function reporterHash(key: string): Promise { return hmacSha256Hex(cookieSecret(), `abuse-reporter:${key.trim().toLowerCase()}`); } /** Holds a site for review. Sites already disabled for another reason keep that reason. */ export async function pauseSite(siteId: string): Promise { const now = Date.now(); const paused = await run( "UPDATE sites SET disabled_reason = ?, paused_at = ?, updated_at = ? WHERE id = ? AND disabled_reason IS NULL", PAUSE_REASON, now, now, siteId, ); if (!paused.meta.changes) return false; const site = await loadSiteById(siteId); if (site) await publishMeta(site); return true; } /** Lifts an abuse pause only; disables from expiry, sync or the scratch reaper stay in place. */ export async function restoreSite(siteId: string): Promise { const restored = await run( "UPDATE sites SET disabled_reason = NULL, paused_at = NULL, updated_at = ? WHERE id = ? AND paused_at IS NOT NULL", Date.now(), siteId, ); if (!restored.meta.changes) return false; const site = await loadSiteById(siteId); if (site) await publishMeta(site); return true; } async function maybeAutoPause(siteId: string, category: AbuseCategory): Promise { const site = await first<{ scratch: number; disabled_reason: string | null; label: string }>( "SELECT scratch, disabled_reason, label FROM sites WHERE id = ?", siteId, ); if (!site || site.disabled_reason) return false; const tier = site.scratch ? PAUSE_THRESHOLDS.scratch : PAUSE_THRESHOLDS.owned; const counts = await first<{ total: number | null; severe: number | null }>( `SELECT SUM(weight) AS total, SUM(CASE WHEN severe THEN weight ELSE 0 END) AS severe FROM ( SELECT reporter_hash, MAX(reporter_weight) AS weight, MAX(category IN ('phishing', 'malware')) AS severe FROM abuse_reports WHERE site_id = ? AND status = 'open' AND created_at > ? AND reporter_weight >= ? GROUP BY reporter_hash)`, siteId, Date.now() - PAUSE_WINDOW_MS, tier.minWeight, ); const total = counts?.total ?? 0; const severe = counts?.severe ?? 0; const trip = total >= tier.normal || (SEVERE.has(category) && severe >= tier.severe); if (!trip || !(await pauseSite(siteId))) return false; await notifyAdmins( `Site ${site.label} was auto-paused after abuse reports`, [`Weighted reporters: ${total}`, `Category: ${category}`, site.scratch ? "Scratch site" : "Owned site"], `abuse-paused:${siteId}:${Date.now()}`, "/admin/abuse", ); return true; } async function linkTarget(input: AbuseReportInput, targetUrl: string): Promise { if (input.siteId) return { kind: "site", id: input.siteId }; return resolveAbuseTarget(targetUrl); } /** * Records a report (one per reporter per target while open), tells the admins, and pauses a hosted site once * enough weighted reporters agree. */ export async function recordAbuseReport( input: AbuseReportInput, ): Promise<{ siteId: string | null; target: AbuseTarget | null; paused: boolean }> { const targetUrl = input.targetUrl.trim().slice(0, MAX_URL); if (!targetUrl) throw badRequest("Tell us which page you are reporting"); const target = await linkTarget(input, targetUrl); const siteId = target?.kind === "site" ? target.id : null; const hash = await reporterHash(input.reporterKey); const duplicate = target ? await first<{ id: string }>( `SELECT id FROM abuse_reports WHERE target_kind = ? AND target_id = ? AND reporter_hash = ? AND status = 'open'`, target.kind, target.id, hash, ) : null; if (!duplicate) { const id = randomId(); await run( `INSERT INTO abuse_reports (id, source, target_url, site_id, target_kind, target_id, category, details, reporter_email, reporter_hash, reporter_weight, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.source, targetUrl, siteId, target?.kind ?? null, target?.id ?? null, input.category, input.details.trim().slice(0, MAX_DETAILS), input.reporterEmail?.trim().slice(0, 320) || null, hash, REPORTER_WEIGHT[input.reporterKind], Date.now(), ); await notifyAdmins( `New abuse report: ${input.category}`, [ `Target: ${target ? ABUSE_TARGET_LABELS[target.kind] : "Unlinked URL"}`, `URL: ${targetUrl}`, `Source: ${input.source} (${input.reporterKind})`, ], `abuse-report:${id}`, "/admin/abuse", ); } const paused = siteId ? await maybeAutoPause(siteId, input.category) : false; captureServerEvent("abuse_report_created", null, { source: input.source, category: input.category, duplicate: duplicate !== null, site: siteId !== null, target_kind: target?.kind ?? "unlinked", paused, }); return { siteId, target, paused }; } export type AbuseQueueEntry = AbuseReportRow & { site_label: string | null; site_paused_at: number | null; site_disabled_reason: string | null; owner_name: string | null; /** Hidden repo, issue or comment, or taken-down Pages project. */ target_hidden: number; }; export async function listAbuseReports(status: AbuseStatus, limit = 100): Promise { return all( `SELECT r.id, r.source, r.target_url, r.site_id, r.target_kind, r.target_id, r.category, r.details, r.reporter_email, r.reporter_weight, r.status, r.resolution, r.resolved_by, r.resolved_at, r.created_at, s.label AS site_label, s.paused_at AS site_paused_at, s.disabled_reason AS site_disabled_reason, COALESCE(a.name, (SELECT ra.name FROM repos rr JOIN accounts ra ON ra.id = rr.owner_id WHERE r.target_kind IN ('repo', 'pages') AND rr.id = r.target_id)) AS owner_name, CASE r.target_kind WHEN 'repo' THEN (SELECT hidden_at IS NOT NULL FROM repos WHERE id = r.target_id) WHEN 'issue' THEN (SELECT hidden_at IS NOT NULL FROM issues WHERE id = r.target_id) WHEN 'comment' THEN (SELECT hidden_at IS NOT NULL FROM comments WHERE id = r.target_id) WHEN 'review_comment' THEN (SELECT hidden_at IS NOT NULL FROM review_comments WHERE id = r.target_id) WHEN 'discussion_comment' THEN (SELECT hidden_at IS NOT NULL FROM discussion_comments WHERE id = r.target_id) WHEN 'pages' THEN (SELECT taken_down_at IS NOT NULL FROM pages_sites WHERE repo_id = r.target_id) ELSE 0 END AS target_hidden FROM abuse_reports r LEFT JOIN sites s ON s.id = r.site_id LEFT JOIN accounts a ON a.id = s.owner_id WHERE r.status = ? ORDER BY r.created_at DESC LIMIT ?`, status, limit, ); } const RESOLUTIONS: Record = { pause: "Site paused", restore: "Site restored", hide: "Target hidden", unhide: "Target restored", dismiss: "Dismissed", resolve: "Resolved", }; /** Pausing, hiding or resolving action the report; restoring or unhiding dismisses it as unfounded. */ const ACTION_STATUS: Record = { pause: "actioned", hide: "actioned", resolve: "actioned", restore: "dismissed", unhide: "dismissed", dismiss: "dismissed", }; /** * Admin decision on a report. Every action except a plain dismiss closes all open reports against the same * target, and each one is written to the admin audit log. */ export async function actOnAbuseReport( reportId: string, action: AbuseAction, adminId: string, reason: string | null = null, ): Promise { const report = await first<{ site_id: string | null; target_kind: string | null; target_id: string | null }>( "SELECT site_id, target_kind, target_id FROM abuse_reports WHERE id = ?", reportId, ); if (!report) throw notFoundError("Report not found"); if ((action === "pause" || action === "restore") && !report.site_id) { throw badRequest("This report is not linked to a hosted site"); } if ((action === "hide" || action === "unhide") && !(report.target_id && isHideable(report.target_kind ?? ""))) { throw badRequest("This report is not about a repository, issue, comment or Pages project"); } if (action === "pause" && report.site_id) await pauseSite(report.site_id); if (action === "restore" && report.site_id) await restoreSite(report.site_id); if ((action === "hide" || action === "unhide") && report.target_kind && report.target_id) { await setModerationHidden(report.target_kind, report.target_id, action === "hide", adminId, reason); } const now = Date.now(); const params = [ACTION_STATUS[action], RESOLUTIONS[action], adminId, now] as const; if (action !== "dismiss" && report.target_kind && report.target_id) { await run( `UPDATE abuse_reports SET status = ?, resolution = ?, resolved_by = ?, resolved_at = ? WHERE (target_kind = ? AND target_id = ? AND status = 'open') OR id = ?`, ...params, report.target_kind, report.target_id, reportId, ); } else { await run( "UPDATE abuse_reports SET status = ?, resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?", ...params, reportId, ); } await recordAdminAction({ actorId: adminId, action: `abuse.${action}`, targetKind: "abuse_report", targetId: reportId, reason, }); } export function abuseForwardAddress(): string | null { const address = env.ABUSE_FORWARD_TO?.trim(); return address ? address : null; }