From 6b1b1d0417fa89d2ed1504590eda3735a23bfc35 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 13 Mar 2026 15:02:09 +1100 Subject: [PATCH] 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. --- .../components/general/webhook-logs-sheet.tsx | 42 +-- package-lock.json | 337 +++++++++++++++++- packages/lib/errors/app-error.ts | 1 + .../internal/execute-webhook.handler.ts | 42 +-- packages/lib/package.json | 5 +- .../webhooks/assert-webhook-url.test.ts | 129 +++++++ .../webhooks/assert-webhook-url.ts | 79 ++++ .../webhooks/execute-webhook-call.ts | 60 ++++ .../webhooks/is-private-url.test.ts | 113 ++++++ .../server-only/webhooks/is-private-url.ts | 82 +++++ .../server-only/webhooks/zapier/subscribe.ts | 8 + packages/lib/utils/timeout.ts | 37 ++ packages/lib/vitest.config.ts | 7 + .../webhook-router/resend-webhook-call.ts | 44 +-- .../resend-webhook-call.types.ts | 17 +- packages/trpc/server/webhook-router/schema.ts | 11 +- 16 files changed, 907 insertions(+), 107 deletions(-) create mode 100644 packages/lib/server-only/webhooks/assert-webhook-url.test.ts create mode 100644 packages/lib/server-only/webhooks/assert-webhook-url.ts create mode 100644 packages/lib/server-only/webhooks/execute-webhook-call.ts create mode 100644 packages/lib/server-only/webhooks/is-private-url.test.ts create mode 100644 packages/lib/server-only/webhooks/is-private-url.ts create mode 100644 packages/lib/utils/timeout.ts create mode 100644 packages/lib/vitest.config.ts diff --git a/apps/remix/app/components/general/webhook-logs-sheet.tsx b/apps/remix/app/components/general/webhook-logs-sheet.tsx index f09b56822..9e18f8e81 100644 --- a/apps/remix/app/components/general/webhook-logs-sheet.tsx +++ b/apps/remix/app/components/general/webhook-logs-sheet.tsx @@ -19,20 +19,16 @@ export type WebhookLogsSheetProps = { }; export const WebhookLogsSheet = createCallable( - ({ 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 Webhook Details -

{webhookCall.id}

+

{webhookCall.id}

{/* Content */}
-

+

Details

-
+
- + {generalWebhookDetails.map(({ header, value }, index) => ( - - @@ -118,13 +114,13 @@ export const WebhookLogsSheet = createCallable -
+
-
+                
                   {JSON.stringify(
                     activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
                     null,
@@ -166,19 +162,19 @@ export const WebhookLogsSheet = createCallable
-                  

+

Response Headers

-
+
+ {header} + {value}
- + {Object.entries(webhookCall.responseHeaders as Record).map( ([key, value]) => ( - - diff --git a/package-lock.json b/package-lock.json index a3aab94d1..574e3c12c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17126,6 +17126,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -17423,6 +17434,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -18208,6 +18226,141 @@ "node": ">= 20" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vvo/tzdb": { "version": "6.196.0", "resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.196.0.tgz", @@ -18648,6 +18801,16 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -19382,6 +19545,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -23635,6 +23808,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -29838,6 +30021,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -33502,6 +33696,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -33882,6 +34083,13 @@ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/start-server-and-test": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.3.tgz", @@ -33915,6 +34123,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -34951,6 +35166,13 @@ "esm": "^3.2.25" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -34976,6 +35198,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -36185,6 +36417,91 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -36385,6 +36702,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -37179,7 +37513,8 @@ "devDependencies": { "@playwright/browser-chromium": "1.56.1", "@types/luxon": "^3.7.1", - "@types/pg": "^8.15.6" + "@types/pg": "^8.15.6", + "vitest": "^4.0.18" } }, "packages/prettier-config": { diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index fc5681aee..48156d077 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -19,6 +19,7 @@ export enum AppErrorCode { 'SCHEMA_FAILED' = 'SCHEMA_FAILED', 'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS', 'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED', + 'WEBHOOK_INVALID_REQUEST' = 'WEBHOOK_INVALID_REQUEST', } export const genericErrorCodeToTrpcErrorCodeMap: Record = diff --git a/packages/lib/jobs/definitions/internal/execute-webhook.handler.ts b/packages/lib/jobs/definitions/internal/execute-webhook.handler.ts index 2e1799d70..afb167b2b 100644 --- a/packages/lib/jobs/definitions/internal/execute-webhook.handler.ts +++ b/packages/lib/jobs/definitions/internal/execute-webhook.handler.ts @@ -1,5 +1,7 @@ -import { Prisma, WebhookCallStatus } from '@prisma/client'; +import type { Prisma } from '@prisma/client'; +import { WebhookCallStatus } from '@prisma/client'; +import { executeWebhookCall } from '@documenso/lib/server-only/webhooks/execute-webhook-call'; import { prisma } from '@documenso/prisma'; import type { JobRunIO } from '../../client/_internal/job'; @@ -7,7 +9,7 @@ import type { TExecuteWebhookJobDefinition } from './execute-webhook'; export const run = async ({ payload, - io, + io: _io, }: { payload: TExecuteWebhookJobDefinition; io: JobRunIO; @@ -29,44 +31,28 @@ export const run = async ({ webhookEndpoint: url, }; - const response = await fetch(url, { - method: 'POST', - body: JSON.stringify(payloadData), - headers: { - 'Content-Type': 'application/json', - 'X-Documenso-Secret': secret ?? '', - }, - }); - - const body = await response.text(); - - let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull; - - try { - responseBody = JSON.parse(body); - } catch (err) { - responseBody = body; - } + const result = await executeWebhookCall({ url, body: payloadData, secret }); await prisma.webhookCall.create({ data: { url, event, - status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED, + status: result.success ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions requestBody: payloadData as Prisma.InputJsonValue, - responseCode: response.status, - responseBody, - responseHeaders: Object.fromEntries(response.headers.entries()), + responseCode: result.responseCode, + responseBody: result.responseBody, + responseHeaders: result.responseHeaders, webhookId: webhook.id, }, }); - if (!response.ok) { - throw new Error(`Webhook execution failed with status ${response.status}`); + if (!result.success) { + throw new Error(`Webhook execution failed with status ${result.responseCode}`); } return { - success: response.ok, - status: response.status, + success: true, + status: result.responseCode, }; }; diff --git a/packages/lib/package.json b/packages/lib/package.json index a81488699..8e8d13d81 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -10,6 +10,8 @@ "universal/" ], "scripts": { + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix", "clean": "rimraf node_modules" @@ -65,6 +67,7 @@ "devDependencies": { "@playwright/browser-chromium": "1.56.1", "@types/luxon": "^3.7.1", - "@types/pg": "^8.15.6" + "@types/pg": "^8.15.6", + "vitest": "^4.0.18" } } diff --git a/packages/lib/server-only/webhooks/assert-webhook-url.test.ts b/packages/lib/server-only/webhooks/assert-webhook-url.test.ts new file mode 100644 index 000000000..6bf84bb5a --- /dev/null +++ b/packages/lib/server-only/webhooks/assert-webhook-url.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { assertNotPrivateUrl } from './assert-webhook-url'; + +const fakeLookup = (addresses: Array<{ address: string; family: number }>) => { + return vi.fn().mockResolvedValue(addresses); +}; + +const fakeLookupSingle = (address: string, family: number) => { + return vi.fn().mockResolvedValue({ address, family }); +}; + +describe('assertNotPrivateUrl', () => { + describe('static URL checks', () => { + it('should throw for localhost URLs', async () => { + await expect(assertNotPrivateUrl('http://localhost:3000')).rejects.toThrow(AppError); + }); + + it('should throw for 127.0.0.1', async () => { + await expect(assertNotPrivateUrl('http://127.0.0.1')).rejects.toThrow(AppError); + }); + + it('should throw for private IPs before DNS lookup', async () => { + await expect(assertNotPrivateUrl('http://10.0.0.1')).rejects.toThrow(AppError); + await expect(assertNotPrivateUrl('http://192.168.1.1')).rejects.toThrow(AppError); + }); + + it('should throw with WEBHOOK_INVALID_REQUEST error code', async () => { + try { + await assertNotPrivateUrl('http://localhost'); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).code).toBe(AppErrorCode.WEBHOOK_INVALID_REQUEST); + } + }); + }); + + describe('DNS resolution checks', () => { + it('should throw when hostname resolves to a private IPv4 address', async () => { + const lookup = fakeLookup([{ address: '127.0.0.1', family: 4 }]); + + await expect( + assertNotPrivateUrl('https://evil.example.com', { lookup }), + ).rejects.toThrow(AppError); + }); + + it('should throw when hostname resolves to a private IPv6 address', async () => { + const lookup = fakeLookup([{ address: '::1', family: 6 }]); + + await expect( + assertNotPrivateUrl('https://evil.example.com', { lookup }), + ).rejects.toThrow(AppError); + }); + + it('should throw when any resolved address is private', async () => { + const lookup = fakeLookup([ + { address: '8.8.8.8', family: 4 }, + { address: '127.0.0.1', family: 4 }, + ]); + + await expect( + assertNotPrivateUrl('https://evil.example.com', { lookup }), + ).rejects.toThrow(AppError); + }); + + it('should allow hostnames that resolve to public addresses', async () => { + const lookup = fakeLookup([{ address: '93.184.216.34', family: 4 }]); + + await expect( + assertNotPrivateUrl('https://example.com', { lookup }), + ).resolves.toBeUndefined(); + }); + + it('should handle a single address result (non-array)', async () => { + const lookup = fakeLookupSingle('10.0.0.1', 4); + + await expect( + assertNotPrivateUrl('https://evil.example.com', { lookup }), + ).rejects.toThrow(AppError); + }); + + it('should handle a single public address result', async () => { + const lookup = fakeLookupSingle('93.184.216.34', 4); + + await expect( + assertNotPrivateUrl('https://example.com', { lookup }), + ).resolves.toBeUndefined(); + }); + }); + + describe('IP address URLs skip DNS', () => { + it('should not perform DNS lookup for IP address URLs', async () => { + const lookup = vi.fn(); + + await assertNotPrivateUrl('http://8.8.8.8', { lookup }); + expect(lookup).not.toHaveBeenCalled(); + }); + }); + + describe('DNS failure handling', () => { + it('should silently allow when DNS lookup throws', async () => { + const lookup = vi.fn().mockRejectedValue(new Error('ENOTFOUND')); + + await expect( + assertNotPrivateUrl('https://nonexistent.example.com', { lookup }), + ).resolves.toBeUndefined(); + }); + + it('should re-throw AppError even within the catch block', async () => { + const lookup = fakeLookup([{ address: '192.168.0.1', family: 4 }]); + + await expect( + assertNotPrivateUrl('https://evil.example.com', { lookup }), + ).rejects.toThrow(AppError); + }); + + it('should silently allow when DNS lookup times out (returns null)', async () => { + const lookup = vi.fn().mockReturnValue(new Promise(() => {})); + + // withTimeout races the lookup against a 250ms timer and returns null + // if the lookup doesn't settle in time, so the function returns early. + await expect( + assertNotPrivateUrl('https://slow.example.com', { lookup }), + ).resolves.toBeUndefined(); + }, 10_000); + }); +}); diff --git a/packages/lib/server-only/webhooks/assert-webhook-url.ts b/packages/lib/server-only/webhooks/assert-webhook-url.ts new file mode 100644 index 000000000..853f231c1 --- /dev/null +++ b/packages/lib/server-only/webhooks/assert-webhook-url.ts @@ -0,0 +1,79 @@ +import { lookup } from 'node:dns/promises'; +import { z } from 'zod'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { withTimeout } from '../../utils/timeout'; +import { isPrivateUrl } from './is-private-url'; + +const ZIpSchema = z.string().ip(); +const WEBHOOK_DNS_LOOKUP_TIMEOUT_MS = 250; + +type TLookupAddress = { + address: string; + family: number; +}; + +type TLookupFn = ( + hostname: string, + options: { + all: true; + verbatim: true; + }, +) => Promise; + +const normalizeHostname = (hostname: string) => hostname.toLowerCase().replace(/\.+$/, ''); + +const toAddressUrl = (address: string) => + address.includes(':') ? `http://[${address}]` : `http://${address}`; + +/** + * Asserts that a webhook URL does not resolve to a private or loopback + * address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does. + */ +export const assertNotPrivateUrl = async ( + url: string, + options?: { + lookup?: TLookupFn; + }, +) => { + if (isPrivateUrl(url)) { + throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, { + message: 'Webhook URL resolves to a private or loopback address', + }); + } + + try { + const hostname = normalizeHostname(new URL(url).hostname); + + if (hostname.length === 0 || ZIpSchema.safeParse(hostname).success) { + return; + } + + const resolveHostname = options?.lookup ?? lookup; + const lookupResult = await withTimeout( + resolveHostname(hostname, { + all: true, + verbatim: true, + }), + WEBHOOK_DNS_LOOKUP_TIMEOUT_MS, + ); + + if (!lookupResult) { + return; + } + + const addresses = Array.isArray(lookupResult) ? lookupResult : [lookupResult]; + + if (addresses.some(({ address }) => isPrivateUrl(toAddressUrl(address)))) { + throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, { + message: 'Webhook URL resolves to a private or loopback address', + }); + } + } catch (err) { + if (err instanceof AppError) { + throw err; + } + + return; + } +}; diff --git a/packages/lib/server-only/webhooks/execute-webhook-call.ts b/packages/lib/server-only/webhooks/execute-webhook-call.ts new file mode 100644 index 000000000..c0c505f79 --- /dev/null +++ b/packages/lib/server-only/webhooks/execute-webhook-call.ts @@ -0,0 +1,60 @@ +import type { Prisma } from '@prisma/client'; + +import { fetchWithTimeout } from '../../utils/timeout'; +import { assertNotPrivateUrl } from './assert-webhook-url'; + +const WEBHOOK_TIMEOUT_MS = 10_000; + +export type WebhookCallResult = { + success: boolean; + responseCode: number; + responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput; + responseHeaders: Record; +}; + +const parseBody = (text: string): Prisma.InputJsonValue => { + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +export const executeWebhookCall = async (options: { + url: string; + body: unknown; + secret: string | null; +}): Promise => { + const { url, body, secret } = options; + + try { + await assertNotPrivateUrl(url); + + const response = await fetchWithTimeout(url, { + method: 'POST', + body: JSON.stringify(body), + redirect: 'manual', + timeoutMs: WEBHOOK_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'X-Documenso-Secret': secret ?? '', + }, + }); + + const text = await response.text(); + + return { + success: response.ok, + responseCode: response.status, + responseBody: parseBody(text), + responseHeaders: Object.fromEntries(response.headers.entries()), + }; + } catch (err) { + return { + success: false, + responseCode: 0, + responseBody: err instanceof Error ? err.message : 'Unknown error', + responseHeaders: {}, + }; + } +}; diff --git a/packages/lib/server-only/webhooks/is-private-url.test.ts b/packages/lib/server-only/webhooks/is-private-url.test.ts new file mode 100644 index 000000000..841ed7938 --- /dev/null +++ b/packages/lib/server-only/webhooks/is-private-url.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { isPrivateUrl } from './is-private-url'; + +describe('isPrivateUrl', () => { + describe('localhost', () => { + it('should detect localhost', () => { + expect(isPrivateUrl('http://localhost')).toBe(true); + expect(isPrivateUrl('http://localhost:3000')).toBe(true); + expect(isPrivateUrl('https://localhost/path')).toBe(true); + }); + + it('should detect localhost with trailing dot', () => { + expect(isPrivateUrl('http://localhost.')).toBe(true); + }); + + it('should be case insensitive', () => { + expect(isPrivateUrl('http://LOCALHOST')).toBe(true); + expect(isPrivateUrl('http://Localhost:8080')).toBe(true); + }); + }); + + describe('IPv4 loopback', () => { + it('should detect 127.0.0.1', () => { + expect(isPrivateUrl('http://127.0.0.1')).toBe(true); + expect(isPrivateUrl('http://127.0.0.1:8080')).toBe(true); + }); + + it('should detect the full 127.x.x.x range', () => { + expect(isPrivateUrl('http://127.0.0.2')).toBe(true); + expect(isPrivateUrl('http://127.255.255.255')).toBe(true); + }); + }); + + describe('IPv4 private ranges', () => { + it('should detect 10.x.x.x', () => { + expect(isPrivateUrl('http://10.0.0.1')).toBe(true); + expect(isPrivateUrl('http://10.255.255.255')).toBe(true); + }); + + it('should detect 172.16.0.0/12', () => { + expect(isPrivateUrl('http://172.16.0.1')).toBe(true); + expect(isPrivateUrl('http://172.31.255.255')).toBe(true); + }); + + it('should not flag 172.x outside the /12 range', () => { + expect(isPrivateUrl('http://172.15.0.1')).toBe(false); + expect(isPrivateUrl('http://172.32.0.1')).toBe(false); + }); + + it('should detect 192.168.x.x', () => { + expect(isPrivateUrl('http://192.168.0.1')).toBe(true); + expect(isPrivateUrl('http://192.168.255.255')).toBe(true); + }); + + it('should detect link-local 169.254.x.x', () => { + expect(isPrivateUrl('http://169.254.1.1')).toBe(true); + }); + + it('should detect 0.0.0.0', () => { + expect(isPrivateUrl('http://0.0.0.0')).toBe(true); + }); + }); + + describe('IPv6', () => { + it('should detect ::1 loopback', () => { + expect(isPrivateUrl('http://[::1]')).toBe(true); + expect(isPrivateUrl('http://[::1]:3000')).toBe(true); + }); + + it('should detect :: unspecified', () => { + expect(isPrivateUrl('http://[::]')).toBe(true); + }); + + it('should detect link-local fe80:', () => { + expect(isPrivateUrl('http://[fe80::1]')).toBe(true); + }); + + it('should detect unique local fc/fd', () => { + expect(isPrivateUrl('http://[fc00::1]')).toBe(true); + expect(isPrivateUrl('http://[fd12::1]')).toBe(true); + }); + + it('should not catch IPv4-mapped IPv6 in URL form (URL parser normalizes to hex)', () => { + // new URL() normalizes "::ffff:127.0.0.1" to "::ffff:7f00:1" which none + // of the checks handle. This is fine because dns.lookup never returns + // IPv4-mapped addresses — it returns plain IPv4 (family: 4) instead. + expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(false); + expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(false); + expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false); + }); + }); + + describe('public URLs', () => { + it('should allow public hostnames', () => { + expect(isPrivateUrl('https://example.com')).toBe(false); + expect(isPrivateUrl('https://api.documenso.com/webhook')).toBe(false); + }); + + it('should allow public IP addresses', () => { + expect(isPrivateUrl('http://8.8.8.8')).toBe(false); + expect(isPrivateUrl('http://1.1.1.1')).toBe(false); + expect(isPrivateUrl('http://203.0.113.1')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false for invalid URLs', () => { + expect(isPrivateUrl('not-a-url')).toBe(false); + expect(isPrivateUrl('')).toBe(false); + }); + }); +}); diff --git a/packages/lib/server-only/webhooks/is-private-url.ts b/packages/lib/server-only/webhooks/is-private-url.ts new file mode 100644 index 000000000..8b345d15a --- /dev/null +++ b/packages/lib/server-only/webhooks/is-private-url.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +const ZIpSchema = z.string().ip(); + +/** + * Check whether a URL points to a known private/loopback address. + * + * Performs a synchronous check against known private hostnames and IP ranges. + * Works regardless of the URL protocol. + */ +export const isPrivateUrl = (url: string): boolean => { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + + // Strip IPv6 brackets. + const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname; + const normalizedHost = bare.replace(/\.+$/, ''); + + if (normalizedHost === 'localhost') { + return true; + } + + const parsedIp = ZIpSchema.safeParse(normalizedHost); + + if (!parsedIp.success) { + return false; + } + + if (normalizedHost === '::1' || normalizedHost === '::') { + return true; + } + + if (normalizedHost === '0.0.0.0') { + return true; + } + + if (normalizedHost.startsWith('127.')) { + return true; + } + + if (normalizedHost.startsWith('10.')) { + return true; + } + + if (normalizedHost.startsWith('192.168.')) { + return true; + } + + if (normalizedHost.startsWith('169.254.')) { + return true; + } + + if (normalizedHost.startsWith('fe80:')) { + return true; + } + + if (normalizedHost.startsWith('fc') || normalizedHost.startsWith('fd')) { + return true; + } + + // 172.16.0.0/12 + if (normalizedHost.startsWith('172.')) { + const second = parseInt(normalizedHost.split('.')[1], 10); + + if (second >= 16 && second <= 31) { + return true; + } + } + + // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) + const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + + if (v4Mapped) { + return isPrivateUrl(`http://${v4Mapped[1]}`); + } + + return false; + } catch { + return false; + } +}; diff --git a/packages/lib/server-only/webhooks/zapier/subscribe.ts b/packages/lib/server-only/webhooks/zapier/subscribe.ts index a72ad4738..370995dd8 100644 --- a/packages/lib/server-only/webhooks/zapier/subscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts @@ -1,5 +1,7 @@ +import { AppError } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; +import { assertNotPrivateUrl } from '../assert-webhook-url'; import { validateApiToken } from './validateApiToken'; export const subscribeHandler = async (req: Request) => { @@ -12,6 +14,8 @@ export const subscribeHandler = async (req: Request) => { const { webhookUrl, eventTrigger } = await req.json(); + await assertNotPrivateUrl(webhookUrl); + const result = await validateApiToken({ authorization }); const createdWebhook = await prisma.webhook.create({ @@ -27,6 +31,10 @@ export const subscribeHandler = async (req: Request) => { return Response.json(createdWebhook); } catch (err) { + if (err instanceof AppError) { + return Response.json({ message: err.message }, { status: 400 }); + } + console.error(err); return Response.json( diff --git a/packages/lib/utils/timeout.ts b/packages/lib/utils/timeout.ts new file mode 100644 index 000000000..d57ca77d0 --- /dev/null +++ b/packages/lib/utils/timeout.ts @@ -0,0 +1,37 @@ +/** + * Race a promise against a timeout. Returns `null` if the timeout + * fires before the promise settles. + */ +export const withTimeout = async (promise: Promise, timeoutMs: number) => + await Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => resolve(null), timeoutMs); + }), + ]); + +/** + * Wrapper around `fetch` that aborts the request after `timeoutMs`. + * Throws with a descriptive message on timeout. + */ +export const fetchWithTimeout = async ( + input: string | URL | Request, + init: RequestInit & { timeoutMs: number }, +) => { + const { timeoutMs, ...fetchInit } = init; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + return await fetch(input, { ...fetchInit, signal: controller.signal }); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error(`Request timed out after ${timeoutMs}ms`); + } + + throw err; + } finally { + clearTimeout(timeout); + } +}; diff --git a/packages/lib/vitest.config.ts b/packages/lib/vitest.config.ts new file mode 100644 index 000000000..2eec34017 --- /dev/null +++ b/packages/lib/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['**/*.test.ts'], + }, +}); diff --git a/packages/trpc/server/webhook-router/resend-webhook-call.ts b/packages/trpc/server/webhook-router/resend-webhook-call.ts index abb0ca34e..298def29c 100644 --- a/packages/trpc/server/webhook-router/resend-webhook-call.ts +++ b/packages/trpc/server/webhook-router/resend-webhook-call.ts @@ -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, }, }); }); diff --git a/packages/trpc/server/webhook-router/resend-webhook-call.types.ts b/packages/trpc/server/webhook-router/resend-webhook-call.types.ts index f34b9ebc6..8afd44e03 100644 --- a/packages/trpc/server/webhook-router/resend-webhook-call.types.ts +++ b/packages/trpc/server/webhook-router/resend-webhook-call.types.ts @@ -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; export type TResendWebhookResponse = z.infer; diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts index c8c8f5851..c5f2b1b53 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -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' }),
+ {key} + {value as string}