fix: improve webhook execution (#2608)

Webhook URLs were being fetched without validating whether they
resolved to private/loopback addresses, exposing the server to SSRF.

Current SSRF is best effort and fail open, you should never host
services that
you cant risk exposure of.

This extracts webhook execution into a shared module that validates
URLs against private IP ranges (including DNS resolution), enforces
timeouts, and disables redirect following. The resend route now
queues through the job system instead of calling fetch inline.
This commit is contained in:
Lucas Smith
2026-03-13 15:02:09 +11:00
committed by GitHub
parent 9f680c7a61
commit 6b1b1d0417
16 changed files with 907 additions and 107 deletions
@@ -19,20 +19,16 @@ export type WebhookLogsSheetProps = {
};
export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | null>(
({ call, webhookCall: initialWebhookCall }) => {
({ call, webhookCall }) => {
const { t } = useLingui();
const { toast } = useToast();
const [webhookCall, setWebhookCall] = useState(initialWebhookCall);
const [activeTab, setActiveTab] = useState<'request' | 'response'>('request');
const { mutateAsync: resendWebhookCall, isPending: isResending } =
trpc.webhook.calls.resend.useMutation({
onSuccess: (result) => {
toast({ title: t`Webhook successfully sent` });
setWebhookCall(result);
onSuccess: () => {
toast({ title: t`Webhook queued for resend` });
},
onError: () => {
toast({ title: t`Something went wrong` });
@@ -71,20 +67,20 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
<h2 className="text-lg font-semibold">
<Trans>Webhook Details</Trans>
</h2>
<p className="text-muted-foreground font-mono text-xs">{webhookCall.id}</p>
<p className="font-mono text-xs text-muted-foreground">{webhookCall.id}</p>
</SheetTitle>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="mt-6">
<div className="flex items-end justify-between">
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
<h4 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<Trans>Details</Trans>
</h4>
<Button
onClick={() =>
resendWebhookCall({
void resendWebhookCall({
webhookId: webhookCall.webhookId,
webhookCallId: webhookCall.id,
})
@@ -98,15 +94,15 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
<Trans>Resend</Trans>
</Button>
</div>
<div className="border-border overflow-hidden rounded-lg border">
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-sm">
<tbody className="divide-border bg-muted/30 divide-y">
<tbody className="divide-y divide-border bg-muted/30">
{generalWebhookDetails.map(({ header, value }, index) => (
<tr key={index}>
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
<td className="w-1/3 border-r border-border px-4 py-2 font-mono text-xs text-muted-foreground">
{header}
</td>
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
<td className="break-all px-4 py-2 font-mono text-xs text-foreground">
{value}
</td>
</tr>
@@ -118,13 +114,13 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
{/* Payload Tabs */}
<div className="py-6">
<div className="border-border mb-4 flex items-center gap-4 border-b">
<div className="mb-4 flex items-center gap-4 border-b border-border">
<button
onClick={() => setActiveTab('request')}
className={cn(
'relative pb-2 text-sm font-medium transition-colors',
activeTab === 'request'
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
? 'text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
@@ -136,7 +132,7 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
className={cn(
'relative pb-2 text-sm font-medium transition-colors',
activeTab === 'response'
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
? 'text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
@@ -155,7 +151,7 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
<pre className="bg-muted/50 border-border text-foreground overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed">
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed text-foreground">
{JSON.stringify(
activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
null,
@@ -166,19 +162,19 @@ export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | n
{activeTab === 'response' && (
<div className="mt-6">
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
<h4 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<Trans>Response Headers</Trans>
</h4>
<div className="border-border overflow-hidden rounded-lg border">
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-sm">
<tbody className="divide-border bg-muted/30 divide-y">
<tbody className="divide-y divide-border bg-muted/30">
{Object.entries(webhookCall.responseHeaders as Record<string, string>).map(
([key, value]) => (
<tr key={key}>
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
<td className="w-1/3 border-r border-border px-4 py-2 font-mono text-xs text-muted-foreground">
{key}
</td>
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
<td className="break-all px-4 py-2 font-mono text-xs text-foreground">
{value as string}
</td>
</tr>