From a1768bdd2ac08664bd5803ab2d088e2e2b4101cd Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Fri, 12 Jun 2026 02:20:24 -0400 Subject: [PATCH] feat(wipes): report wipe status from agent reply + wipe_completed webhook; harden webhook delivery against SSRF Wipe status reporting (closes the wipe_history-stays-pending gap): - triggerWipe now dispatches the wipe non-blocking (a wipe is stop+delete+ start, up to a minute+) and records the outcome from the agent's reply: status -> success/failed, started_at/completed_at, error_message. The row used to be created 'pending' and never advance, so history lied. - On success, fires the third webhook event: 'wipe_completed' (server_down + player_banned shipped in 0effaaf). SSRF hardening (security review HIGH on webhook delivery): - new common/ssrf-guard.ts: resolve the URL host and reject private / loopback / link-local / reserved (v4 + v6, incl. 169.254.169.254 metadata, IPv4-mapped, fc00::/7, fe80::/10). http/https only. - Applied at storage (create/update -> early 400) AND immediately before each delivery (DNS-rebinding/TOCTOU). fetch uses redirect:'manual' so a 3xx can't bounce delivery to an internal host; a redirect is a failed delivery. - Verified IP range math + IPv6 bracket-strip (URL keeps '[::1]') empirically. Backend tsc green. Co-Authored-By: Claude Opus 4.8 --- backend-nest/src/common/ssrf-guard.ts | 100 ++++++++++++++++++ .../src/modules/webhooks/webhooks.service.ts | 16 +++ .../src/modules/wipes/wipes.service.ts | 44 +++++++- 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 backend-nest/src/common/ssrf-guard.ts diff --git a/backend-nest/src/common/ssrf-guard.ts b/backend-nest/src/common/ssrf-guard.ts new file mode 100644 index 0000000..6b14984 --- /dev/null +++ b/backend-nest/src/common/ssrf-guard.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common'; +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + +/** + * SSRF guard for operator-supplied outbound URLs (webhooks today; any future + * "we POST to a URL you give us" feature should reuse this). + * + * The danger: an operator (or anyone who can create a webhook) points the URL at + * an internal address — 127.0.0.1, the NATS/DB ports, 192.168.x, or the cloud + * metadata endpoint 169.254.169.254 — and turns our server into a request proxy + * into the private network. We defend by resolving the host and refusing any + * private / loopback / link-local / reserved destination. + * + * Validate at storage (early, clear 400) AND immediately before each delivery + * (a hostname can resolve public at create time and private at send time — DNS + * rebinding / TOCTOU). `redirect: 'manual'` at the fetch call closes the + * redirect-bounce variant. + */ + +function isBlockedIpv4(ip: string): boolean { + const parts = ip.split('.').map((p) => parseInt(p, 10)); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) { + return true; // unparseable → block defensively + } + const [a, b] = parts; + if (a === 0) return true; // 0.0.0.0/8 "this network" + if (a === 10) return true; // 10.0.0.0/8 private + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata) + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private + if (a === 192 && b === 168) return true; // 192.168.0.0/16 private + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT + if (a === 255) return true; // 255.x broadcast space + return false; +} + +function isBlockedIpv6(ip: string): boolean { + const addr = ip.toLowerCase(); + // IPv4-mapped (::ffff:1.2.3.4) — unwrap and apply the v4 rules. + const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isBlockedIpv4(mapped[1]); + if (addr === '::' || addr === '::1') return true; // unspecified / loopback + const head = addr.split(':')[0]; + if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 ULA + if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local + return false; +} + +function isBlockedIp(ip: string): boolean { + const fam = isIP(ip); + if (fam === 4) return isBlockedIpv4(ip); + if (fam === 6) return isBlockedIpv6(ip); + return true; // not a recognizable IP → block defensively +} + +/** Parse + require http/https scheme. Throws BadRequestException on anything else. */ +export function parseHttpUrl(raw: string): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new BadRequestException('Webhook URL is not a valid URL'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new BadRequestException('Webhook URL must use http:// or https://'); + } + return url; +} + +/** + * Resolve the host and reject if it maps to any private / reserved address. + * If a hostname resolves to multiple addresses, ANY blocked one rejects the + * whole URL (a DNS-rebinding response that mixes a public and a private answer + * must not slip through). Returns the parsed URL on success. + */ +export async function assertPublicHttpUrl(raw: string): Promise { + const url = parseHttpUrl(raw); + // URL keeps IPv6 literals bracketed ("[::1]") — strip so isIP/lookup see the + // bare address; otherwise IPv6 literals never reach the classifier. + const host = url.hostname.replace(/^\[|\]$/g, ''); + + let addresses: Array<{ address: string }>; + if (isIP(host)) { + addresses = [{ address: host }]; + } else { + try { + addresses = await lookup(host, { all: true }); + } catch { + throw new BadRequestException(`Webhook host could not be resolved: ${host}`); + } + } + + if (addresses.length === 0 || addresses.some((a) => isBlockedIp(a.address))) { + throw new BadRequestException( + 'Webhook URL resolves to a private or reserved address and is not allowed', + ); + } + return url; +} diff --git a/backend-nest/src/modules/webhooks/webhooks.service.ts b/backend-nest/src/modules/webhooks/webhooks.service.ts index 864dbb4..f653b7a 100644 --- a/backend-nest/src/modules/webhooks/webhooks.service.ts +++ b/backend-nest/src/modules/webhooks/webhooks.service.ts @@ -5,6 +5,7 @@ import * as crypto from 'crypto'; import { Webhook } from '../../entities/webhook.entity'; import { CreateWebhookDto } from './dto/create-webhook.dto'; import { UpdateWebhookDto } from './dto/update-webhook.dto'; +import { assertPublicHttpUrl } from '../../common/ssrf-guard'; /** Safe list view — secret is included (operator's own resource). */ export interface WebhookListItem { @@ -36,6 +37,9 @@ export class WebhooksService { // --------------------------------------------------------------------------- async create(licenseId: string, dto: CreateWebhookDto): Promise { + // SSRF guard: reject URLs resolving to private/reserved space before storing. + await assertPublicHttpUrl(dto.url); + // Generate a secret if the caller didn't supply one. const secret = dto.secret ?? crypto.randomBytes(32).toString('hex'); @@ -68,6 +72,9 @@ export class WebhooksService { async update(licenseId: string, id: string, dto: UpdateWebhookDto): Promise { const webhook = await this.findOwned(licenseId, id); + // SSRF guard on any URL change. + if (dto.url !== undefined) await assertPublicHttpUrl(dto.url); + if (dto.name !== undefined) webhook.name = dto.name; if (dto.url !== undefined) webhook.url = dto.url; if (dto.events !== undefined) webhook.events = dto.events; @@ -147,6 +154,11 @@ export class WebhooksService { let status: 'ok' | 'failed' = 'failed'; try { + // Re-validate at send time: a host that was public at create time can + // resolve to a private address now (DNS rebinding / TOCTOU). Throws → caught + // below → recorded 'failed'. + await assertPublicHttpUrl(hook.url); + const res = await fetch(hook.url, { method: 'POST', headers: { @@ -155,6 +167,10 @@ export class WebhooksService { }, body, signal: controller.signal, + // Do not auto-follow redirects — a 3xx Location could point at an + // internal host, re-opening the SSRF we just closed. A redirect is a + // failed delivery here. + redirect: 'manual', }); if (res.ok) { diff --git a/backend-nest/src/modules/wipes/wipes.service.ts b/backend-nest/src/modules/wipes/wipes.service.ts index 0ba0f11..ad1e60b 100644 --- a/backend-nest/src/modules/wipes/wipes.service.ts +++ b/backend-nest/src/modules/wipes/wipes.service.ts @@ -15,6 +15,7 @@ import { UpdateProfileDto } from './dto/update-profile.dto'; import { CreateScheduleDto } from './dto/create-schedule.dto'; import { TriggerWipeDto } from './dto/trigger-wipe.dto'; import { InstancesService } from '../instances/instances.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; import { nextCronDate } from '../../common/cron.util'; @Injectable() @@ -30,6 +31,7 @@ export class WipesService implements OnModuleInit, OnModuleDestroy { @InjectRepository(WipeHistory) private readonly wipeHistoryRepo: Repository, private readonly instancesService: InstancesService, + private readonly webhooksService: WebhooksService, ) {} // --------------------------------------------------------------------------- @@ -187,16 +189,50 @@ export class WipesService implements OnModuleInit, OnModuleDestroy { wipe_type: dto.wipe_type, wipe_profile_id: dto.wipe_profile_id, trigger_type: triggerType, - status: 'pending', + status: 'wiping', + started_at: new Date(), }); const saved = await this.wipeHistoryRepo.save(history); - - await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true); this.logger.log( - `Wipe ${triggerType === 'scheduled' ? 'scheduled' : 'triggered'} for license ${licenseId} — history id ${saved.id}`, + `Wipe ${triggerType} dispatched for license ${licenseId} — history ${saved.id}`, ); + // Dispatch to the agent WITHOUT blocking the caller — a wipe is + // stop → delete → start and can take a minute+. We record the outcome on + // wipe_history from the agent's reply and fire the wipe_completed webhook + // when it lands. Previously the row was created 'pending' and never + // advanced, so history lied about every wipe. + void this.instancesService + .wipeForLicense(licenseId, dto.wipe_type, true) + .then((reply: unknown) => { + const r = (reply ?? {}) as { status?: string; message?: string; deleted_count?: number }; + const ok = r.status === 'success'; + saved.status = ok ? 'success' : 'failed'; + saved.completed_at = new Date(); + if (!ok) { + saved.error_message = r.message ?? 'agent reported wipe failure'; + } + return this.wipeHistoryRepo.save(saved).then(() => { + this.logger.log(`Wipe ${saved.id} ${saved.status}`); + if (ok) { + void this.webhooksService.dispatch(licenseId, 'wipe_completed', { + wipe_history_id: saved.id, + wipe_type: dto.wipe_type, + trigger_type: triggerType, + deleted_count: r.deleted_count ?? null, + }); + } + }); + }) + .catch((err: unknown) => { + saved.status = 'failed'; + saved.completed_at = new Date(); + saved.error_message = err instanceof Error ? err.message : 'wipe dispatch failed'; + this.logger.warn(`Wipe ${saved.id} failed: ${saved.error_message}`); + void this.wipeHistoryRepo.save(saved); + }); + return { wipe_history_id: saved.id }; }