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 { Webhook } from '../../entities/webhook.entity';
|
||||||
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||||
import { UpdateWebhookDto } from './dto/update-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). */
|
/** Safe list view — secret is included (operator's own resource). */
|
||||||
export interface WebhookListItem {
|
export interface WebhookListItem {
|
||||||
@@ -36,6 +37,9 @@ export class WebhooksService {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async create(licenseId: string, dto: CreateWebhookDto): Promise<CreatedWebhook> {
|
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.
|
// Generate a secret if the caller didn't supply one.
|
||||||
const secret = dto.secret ?? crypto.randomBytes(32).toString('hex');
|
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> {
|
async update(licenseId: string, id: string, dto: UpdateWebhookDto): Promise<WebhookListItem> {
|
||||||
const webhook = await this.findOwned(licenseId, id);
|
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.name !== undefined) webhook.name = dto.name;
|
||||||
if (dto.url !== undefined) webhook.url = dto.url;
|
if (dto.url !== undefined) webhook.url = dto.url;
|
||||||
if (dto.events !== undefined) webhook.events = dto.events;
|
if (dto.events !== undefined) webhook.events = dto.events;
|
||||||
@@ -147,6 +154,11 @@ export class WebhooksService {
|
|||||||
let status: 'ok' | 'failed' = 'failed';
|
let status: 'ok' | 'failed' = 'failed';
|
||||||
|
|
||||||
try {
|
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, {
|
const res = await fetch(hook.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -155,6 +167,10 @@ export class WebhooksService {
|
|||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
signal: controller.signal,
|
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) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { UpdateProfileDto } from './dto/update-profile.dto';
|
|||||||
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
import { CreateScheduleDto } from './dto/create-schedule.dto';
|
||||||
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
import { TriggerWipeDto } from './dto/trigger-wipe.dto';
|
||||||
import { InstancesService } from '../instances/instances.service';
|
import { InstancesService } from '../instances/instances.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { nextCronDate } from '../../common/cron.util';
|
import { nextCronDate } from '../../common/cron.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -30,6 +31,7 @@ export class WipesService implements OnModuleInit, OnModuleDestroy {
|
|||||||
@InjectRepository(WipeHistory)
|
@InjectRepository(WipeHistory)
|
||||||
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
||||||
private readonly instancesService: InstancesService,
|
private readonly instancesService: InstancesService,
|
||||||
|
private readonly webhooksService: WebhooksService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -187,16 +189,50 @@ export class WipesService implements OnModuleInit, OnModuleDestroy {
|
|||||||
wipe_type: dto.wipe_type,
|
wipe_type: dto.wipe_type,
|
||||||
wipe_profile_id: dto.wipe_profile_id,
|
wipe_profile_id: dto.wipe_profile_id,
|
||||||
trigger_type: triggerType,
|
trigger_type: triggerType,
|
||||||
status: 'pending',
|
status: 'wiping',
|
||||||
|
started_at: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.wipeHistoryRepo.save(history);
|
const saved = await this.wipeHistoryRepo.save(history);
|
||||||
|
|
||||||
await this.instancesService.wipeForLicense(licenseId, dto.wipe_type, true);
|
|
||||||
this.logger.log(
|
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 };
|
return { wipe_history_id: saved.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user