sec: webhook SSRF guard, IMAP-sync owner check, watcher port membership
Three findings from a fourth-pass review: 1. MEDIUM — webhook URL SSRF. The validator only enforced HTTPS+URL parse; it accepted private/loopback/link-local/.internal hosts. The delivery worker fetched arbitrary URLs and persisted up to 1KB of response body into webhook_deliveries.response_body, which is then surfaced via the deliveries listing endpoint — a port admin could register a webhook to an internal HTTPS endpoint, hit the test endpoint to force immediate dispatch, and read the response back. Validator now rejects RFC-1918/loopback/link-local/CGNAT/ULA IPs (v4 + v6) and .internal/.local/.localhost/.lan/.intranet/.corp suffixes; the worker re-resolves the hostname at dispatch time and blocks before fetch (DNS rebinding defense). 21-case unit test covers the matrix. 2. MEDIUM — POST /api/v1/email/accounts/[id]/sync had no owner check. Any user with email:view could enqueue an inbox-sync job for any accountId, which the worker would honour using the foreign user's decrypted IMAP credentials and advance the account's lastSyncAt (data-loss risk on the legitimate owner's next sync). Route now asserts account.userId === ctx.userId before enqueueing, matching the toggle/disconnect endpoints. 3. MEDIUM — addDocumentWatcher (and the wizard / upload watcher inserts) didn't validate the watcher's userId belonged to the document's port. notifyDocumentEvent then emitted a real-time socket toast + email containing the document title to the foreign user. New assertWatchersInPort helper verifies each candidate has a userPortRoles row for the port (super-admin bypass). 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,17 +3,101 @@ import { z } from 'zod';
|
||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
||||
import { WEBHOOK_EVENTS } from '@/lib/services/webhook-event-map';
|
||||
|
||||
// ─── SSRF guards ──────────────────────────────────────────────────────────────
|
||||
|
||||
const BLOCKED_HOSTNAME_SUFFIXES = [
|
||||
'.internal',
|
||||
'.local',
|
||||
'.localhost',
|
||||
'.lan',
|
||||
'.intranet',
|
||||
'.corp',
|
||||
'.home',
|
||||
'.private',
|
||||
];
|
||||
const BLOCKED_HOSTNAME_LITERALS = new Set([
|
||||
'localhost',
|
||||
'0.0.0.0',
|
||||
'255.255.255.255',
|
||||
// IPv6 metadata host (Azure/GCP); literal with brackets handled below
|
||||
'metadata.google.internal',
|
||||
'metadata.azure.internal',
|
||||
]);
|
||||
|
||||
/** Returns true if `host` is an IPv4 address in any disallowed range. */
|
||||
function isBlockedIpv4(host: string): boolean {
|
||||
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (!m) return false;
|
||||
const oct = m.slice(1, 5).map(Number);
|
||||
if (oct.some((o) => o < 0 || o > 255)) return true; // malformed → treat as blocked
|
||||
const [a, b] = oct as [number, number, number, number];
|
||||
if (a === 10) return true; // 10/8 RFC1918
|
||||
if (a === 127) return true; // 127/8 loopback
|
||||
if (a === 169 && b === 254) return true; // 169.254/16 link-local + AWS IMDS
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12 RFC1918
|
||||
if (a === 192 && b === 168) return true; // 192.168/16 RFC1918
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 CGNAT
|
||||
if (a === 0) return true; // 0/8 zero
|
||||
if (a >= 224) return true; // multicast / reserved
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Returns true if `host` is an IPv6 literal in any disallowed range. */
|
||||
function isBlockedIpv6(host: string): boolean {
|
||||
// strip brackets if present
|
||||
const h = host.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
|
||||
if (h === '::1') return true; // loopback
|
||||
if (h === '::') return true; // unspecified
|
||||
if (h.startsWith('fe80:') || h.startsWith('fe80::')) return true; // link-local
|
||||
if (/^f[cd][0-9a-f]{2}:/.test(h)) return true; // fc00::/7 unique-local
|
||||
if (h.startsWith('::ffff:')) {
|
||||
// IPv4-mapped — unwrap and check
|
||||
const ipv4 = h.slice(7);
|
||||
return isBlockedIpv4(ipv4);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject webhook URLs whose hostname targets a private/internal/loopback/
|
||||
* link-local destination. The webhook worker `fetch`es the URL and writes
|
||||
* a slice of the response body into `webhook_deliveries.response_body`,
|
||||
* which is later returned by the deliveries listing endpoint — making any
|
||||
* SSRF here an information-disclosure read primitive against any internal
|
||||
* service the worker can reach. Does NOT defend against DNS rebinding;
|
||||
* the worker performs its own re-resolution at dispatch time.
|
||||
*/
|
||||
export function isLocalOrPrivateHost(rawUrl: string): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (BLOCKED_HOSTNAME_LITERALS.has(host)) return true;
|
||||
if (BLOCKED_HOSTNAME_SUFFIXES.some((s) => host === s.slice(1) || host.endsWith(s))) {
|
||||
return true;
|
||||
}
|
||||
if (host.startsWith('[') || host.includes(':')) {
|
||||
if (isBlockedIpv6(host)) return true;
|
||||
}
|
||||
if (isBlockedIpv4(host)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const urlSchema = z
|
||||
.string()
|
||||
.url('Must be a valid HTTPS URL')
|
||||
.refine((u) => u.startsWith('https://'), 'Webhook URL must use HTTPS')
|
||||
.refine((u) => !isLocalOrPrivateHost(u), 'Webhook URL host is not allowed');
|
||||
|
||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const createWebhookSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url('Must be a valid HTTPS URL').refine(
|
||||
(u) => u.startsWith('https://'),
|
||||
'Webhook URL must use HTTPS',
|
||||
),
|
||||
events: z
|
||||
.array(z.enum(WEBHOOK_EVENTS))
|
||||
.min(1, 'At least one event must be selected'),
|
||||
url: urlSchema,
|
||||
events: z.array(z.enum(WEBHOOK_EVENTS)).min(1, 'At least one event must be selected'),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
@@ -21,11 +105,7 @@ export const createWebhookSchema = z.object({
|
||||
|
||||
export const updateWebhookSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
url: z
|
||||
.string()
|
||||
.url('Must be a valid HTTPS URL')
|
||||
.refine((u) => u.startsWith('https://'), 'Webhook URL must use HTTPS')
|
||||
.optional(),
|
||||
url: urlSchema.optional(),
|
||||
events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user