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
@@ -1,8 +1,6 @@
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { jobs } from '@documenso/lib/jobs/client';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
@@ -35,46 +33,18 @@ export const resendWebhookCallRoute = authenticatedProcedure
}),
},
},
include: {
webhook: true,
},
});
if (!webhookCall) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { webhook } = webhookCall;
// Note: This is duplicated in `execute-webhook.handler.ts`.
const response = await fetch(webhookCall.url, {
method: 'POST',
body: JSON.stringify(webhookCall.requestBody),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': webhook.secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
return await prisma.webhookCall.update({
where: {
id: webhookCall.id,
},
data: {
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
await jobs.triggerJob({
name: 'internal.execute-webhook',
payload: {
event: webhookCall.event,
webhookId,
data: webhookCall.requestBody,
},
});
});
@@ -1,26 +1,11 @@
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
export const ZResendWebhookCallRequestSchema = z.object({
webhookId: z.string(),
webhookCallId: z.string(),
});
export const ZResendWebhookCallResponseSchema = WebhookCallSchema.pick({
webhookId: true,
status: true,
event: true,
id: true,
url: true,
responseCode: true,
createdAt: true,
}).extend({
requestBody: z.unknown(),
responseHeaders: z.unknown().nullable(),
responseBody: z.unknown().nullable(),
});
export const ZResendWebhookCallResponseSchema = z.void();
export type TResendWebhookRequest = z.infer<typeof ZResendWebhookCallRequestSchema>;
export type TResendWebhookResponse = z.infer<typeof ZResendWebhookCallResponseSchema>;
+10 -1
View File
@@ -1,8 +1,17 @@
import { WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import { isPrivateUrl } from '@documenso/lib/server-only/webhooks/is-private-url';
export const ZWebhookUrlSchema = z
.string()
.url()
.refine((url) => !isPrivateUrl(url), {
message: 'Webhook URL cannot point to a private or loopback address',
});
export const ZCreateWebhookRequestSchema = z.object({
webhookUrl: z.string().url(),
webhookUrl: ZWebhookUrlSchema,
eventTriggers: z
.array(z.nativeEnum(WebhookTriggerEvents))
.min(1, { message: 'At least one event trigger is required' }),