feat(wipes): report wipe status from agent reply + wipe_completed webhook; harden webhook delivery against SSRF
All checks were successful
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 45s
CI / integration (push) Successful in 21s

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 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-12 02:20:24 -04:00
parent 0effaaf86c
commit a1768bdd2a
3 changed files with 156 additions and 4 deletions

View File

@@ -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<URL> {
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;
}

View File

@@ -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<CreatedWebhook> {
// 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<WebhookListItem> {
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) {

View File

@@ -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<WipeHistory>,
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 };
}