mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
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:
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>
|
||||
|
|
|
|||
337
package-lock.json
generated
337
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<string, { code: string; status: number }> =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
129
packages/lib/server-only/webhooks/assert-webhook-url.test.ts
Normal file
129
packages/lib/server-only/webhooks/assert-webhook-url.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
79
packages/lib/server-only/webhooks/assert-webhook-url.ts
Normal file
79
packages/lib/server-only/webhooks/assert-webhook-url.ts
Normal file
|
|
@ -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<TLookupAddress[] | TLookupAddress>;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
60
packages/lib/server-only/webhooks/execute-webhook-call.ts
Normal file
60
packages/lib/server-only/webhooks/execute-webhook-call.ts
Normal file
|
|
@ -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<string, string>;
|
||||
};
|
||||
|
||||
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<WebhookCallResult> => {
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
};
|
||||
113
packages/lib/server-only/webhooks/is-private-url.test.ts
Normal file
113
packages/lib/server-only/webhooks/is-private-url.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
82
packages/lib/server-only/webhooks/is-private-url.ts
Normal file
82
packages/lib/server-only/webhooks/is-private-url.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
37
packages/lib/utils/timeout.ts
Normal file
37
packages/lib/utils/timeout.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Race a promise against a timeout. Returns `null` if the timeout
|
||||
* fires before the promise settles.
|
||||
*/
|
||||
export const withTimeout = async <T>(promise: Promise<T>, timeoutMs: number) =>
|
||||
await Promise.race<T | null>([
|
||||
promise,
|
||||
new Promise<null>((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);
|
||||
}
|
||||
};
|
||||
7
packages/lib/vitest.config.ts
Normal file
7
packages/lib/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['**/*.test.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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
Loading…
Reference in a new issue