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 <noreply@anthropic.com>
This commit is contained in:
100
backend-nest/src/common/ssrf-guard.ts
Normal file
100
backend-nest/src/common/ssrf-guard.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user