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}