fix(audit): webhook cluster — M21 (test-send isActive), M22 (cross-tenant dead-letter), L28 (ipv6 SSRF), L29 (rebind doc), L30 (replay event-time)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,10 +14,29 @@ import { isLocalOrPrivateHost } from '@/lib/validators/webhooks';
|
||||
* disallowed range. Defends against DNS rebinding where the validator-time
|
||||
* resolution returned a public address but dispatch-time resolution
|
||||
* returns a private one.
|
||||
*
|
||||
* L29 — residual TOCTOU / DNS-rebind window: this function resolves the host
|
||||
* and validates every returned IP, but the subsequent `fetch` does its OWN
|
||||
* independent DNS resolution. An attacker controlling an authoritative server
|
||||
* with a very short TTL could return a public IP here and a private IP to
|
||||
* `fetch` microseconds later (classic DNS rebinding). Fully closing this
|
||||
* requires PINNING the validated IP — i.e. connecting by the exact address we
|
||||
* checked while preserving Host header + TLS SNI. That needs a custom undici
|
||||
* dispatcher (`undici.Agent({ connect: { lookup } })`); `undici` is not a
|
||||
* direct dependency and `node:undici` is not an importable built-in here, and
|
||||
* rewriting delivery onto `node:https` would discard the redirect/SSRF
|
||||
* handling built around `fetch`. So we do NOT yet pin. What we DO tighten:
|
||||
* - the resolved/validated addresses are returned to the caller so a future
|
||||
* pin can connect to one of them without re-resolving;
|
||||
* - the check runs as LATE as possible (immediately before `fetch`), making
|
||||
* the rebind window as narrow as the time between this lookup and fetch's;
|
||||
* - the redirect SSRF path (the easy route to the same target) is already
|
||||
* closed via `redirect: 'manual'` (audit H1).
|
||||
* The remaining gap is a short-TTL rebind race; tracked as audit L29.
|
||||
*/
|
||||
async function resolveAndCheckHost(
|
||||
rawUrl: string,
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
): Promise<{ ok: true; addresses: string[] } | { ok: false; reason: string }> {
|
||||
if (isLocalOrPrivateHost(rawUrl)) {
|
||||
return { ok: false, reason: 'webhook URL host blocked by static check' };
|
||||
}
|
||||
@@ -35,13 +54,13 @@ async function resolveAndCheckHost(
|
||||
return { ok: false, reason: `resolved address ${a.address} is in a blocked range` };
|
||||
}
|
||||
}
|
||||
return { ok: true, addresses: addresses.map((a) => a.address) };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `DNS resolution failed: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─── Job Payload ─────────────────────────────────────────────────────────────
|
||||
@@ -70,7 +89,7 @@ export const webhooksWorker = new Worker(
|
||||
|
||||
const { db } = await import('@/lib/db');
|
||||
const { webhooks, webhookDeliveries } = await import('@/lib/db/schema/system');
|
||||
const { userProfiles } = await import('@/lib/db/schema/users');
|
||||
const { userProfiles, userPortRoles } = await import('@/lib/db/schema/users');
|
||||
const { decrypt } = await import('@/lib/utils/encryption');
|
||||
const { createNotification } = await import('@/lib/services/notifications.service');
|
||||
const { eq, and } = await import('drizzle-orm');
|
||||
@@ -221,6 +240,13 @@ export const webhooksWorker = new Worker(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
// L29: `fetch` re-resolves `webhook.url`'s hostname independently of the
|
||||
// `resolveAndCheckHost` check just above, so a short-TTL DNS-rebind race
|
||||
// remains (validated public IP here, private IP at connect). We do not
|
||||
// pin `hostCheck.addresses` because that needs a custom undici dispatcher
|
||||
// that isn't importable in this runtime — see resolveAndCheckHost for the
|
||||
// full rationale and the mitigations that are in place. Residual gap is a
|
||||
// narrow rebind window; the easier redirect route is closed below.
|
||||
const response = await fetch(webhook.url, {
|
||||
method: 'POST',
|
||||
// SSRF guard (audit H1): never follow redirects. resolveAndCheckHost
|
||||
@@ -322,14 +348,29 @@ export const webhooksWorker = new Worker(
|
||||
severity: 'error',
|
||||
});
|
||||
|
||||
// Notify all super admins
|
||||
// M22: notify only super-admins who actually operate in the
|
||||
// originating port. The webhook's `description` embeds the
|
||||
// admin-controlled `webhook.name` and a `/admin/webhooks/{id}` link
|
||||
// scoped to THIS tenant; the old query selected every super-admin of
|
||||
// every tenant, leaking Port A's webhook name (and a minor injection
|
||||
// vector) into unrelated tenants' notification feeds. Requiring a
|
||||
// `userPortRoles` row for `portId` keeps the alert inside the tenant.
|
||||
// `select` with the inner join can fan out one row per role, so we
|
||||
// dedupe userIds before notifying.
|
||||
try {
|
||||
const superAdmins = await db
|
||||
.select({ userId: userProfiles.userId })
|
||||
const superAdminRows = await db
|
||||
.selectDistinct({ userId: userProfiles.userId })
|
||||
.from(userProfiles)
|
||||
.where(and(eq(userProfiles.isSuperAdmin, true), eq(userProfiles.isActive, true)));
|
||||
.innerJoin(userPortRoles, eq(userPortRoles.userId, userProfiles.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(userProfiles.isSuperAdmin, true),
|
||||
eq(userProfiles.isActive, true),
|
||||
eq(userPortRoles.portId, portId),
|
||||
),
|
||||
);
|
||||
|
||||
for (const admin of superAdmins) {
|
||||
for (const admin of superAdminRows) {
|
||||
void createNotification({
|
||||
portId,
|
||||
userId: admin.userId,
|
||||
|
||||
Reference in New Issue
Block a user