diff --git a/apps/worker/src/app/workflow/specs/inbound-email-parse.spec.ts b/apps/worker/src/app/workflow/specs/inbound-email-parse.spec.ts index 32c5222b2db..45fd602e4e3 100644 --- a/apps/worker/src/app/workflow/specs/inbound-email-parse.spec.ts +++ b/apps/worker/src/app/workflow/specs/inbound-email-parse.spec.ts @@ -25,7 +25,7 @@ const axiosInstance = axios.create(); const eventTriggerPath = '/v1/events/trigger'; const USER_MAIL_DOMAIN = 'mail.domain.com'; -const USER_PARSE_WEBHOOK = 'user-parse.com/webhook/{{compiledVariable}}'; +const USER_PARSE_WEBHOOK = 'https://example.com/webhook/{{compiledVariable}}'; describe('Should handle the new arrived mail', () => { let inboundEmailParseUsecase: InboundEmailParse; @@ -171,7 +171,7 @@ const getEntitiesStubObject = { active: true, replyCallback: { active: true, - url: 'user-parse.com/webhook/{{compiledVariable}}', + url: 'https://example.com/webhook/{{compiledVariable}}', }, shouldStopOnFail: false, filters: [], @@ -238,7 +238,7 @@ const getEntitiesStubObject = { step: { replyCallback: { active: true, - url: 'user-parse.com/webhook/{{compiledVariable}}', + url: 'https://example.com/webhook/{{compiledVariable}}', }, metadata: { timed: { diff --git a/apps/worker/src/app/workflow/usecases/inbound-email-parse/strategies/reply-to.strategy.ts b/apps/worker/src/app/workflow/usecases/inbound-email-parse/strategies/reply-to.strategy.ts index a39026dac09..5db57ff33ff 100644 --- a/apps/worker/src/app/workflow/usecases/inbound-email-parse/strategies/reply-to.strategy.ts +++ b/apps/worker/src/app/workflow/usecases/inbound-email-parse/strategies/reply-to.strategy.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { CompileTemplate, createHash } from '@novu/application-generic'; +import { CompileTemplate, createHash, normalizeOutboundHttpUrl, validateUrlSsrf } from '@novu/application-generic'; import { JobEntity, JobRepository, @@ -50,6 +50,18 @@ export class ReplyToStrategy { data: job.payload, }); + const requestUrl = normalizeOutboundHttpUrl(compiledDomain); + + if (!requestUrl) { + this.throwError('Reply callback URL blocked (SSRF): Invalid URL format.'); + } + + const ssrfError = await validateUrlSsrf(requestUrl); + + if (ssrfError) { + this.throwError(`Reply callback URL blocked (SSRF): ${ssrfError}`); + } + const userPayload: IUserWebhookPayload = { hmac: createHash(environment?.apiKeys[0]?.key, subscriber.subscriberId) || '', transactionId, @@ -61,7 +73,7 @@ export class ReplyToStrategy { mail: command, }; - await axios.post(compiledDomain, userPayload); + await axios.post(requestUrl, userPayload); } private splitTo(address: string) { diff --git a/libs/application-generic/src/utils/ssrf-url-validation.ts b/libs/application-generic/src/utils/ssrf-url-validation.ts index 204cc57c4d0..3120d33a792 100644 --- a/libs/application-generic/src/utils/ssrf-url-validation.ts +++ b/libs/application-generic/src/utils/ssrf-url-validation.ts @@ -1,6 +1,46 @@ import * as dns from 'node:dns'; import { LRUCache } from 'lru-cache'; +/* Keep in sync with packages/shared/src/utils/ssrf-url-validation.ts */ + +/** + * Resolves a webhook-style URL for outbound HTTP requests. + * Host-only or path-first values (no scheme) are treated as https, matching axios behavior. + */ +export function normalizeOutboundHttpUrl(raw: string): string | null { + const trimmed = raw.trim(); + + if (!trimmed) { + return null; + } + + try { + const parsed = new URL(trimmed); + + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return trimmed; + } + + return null; + } catch { + // Continue: scheme-less host/path (e.g. example.com/hook) + } + + const withHttps = `https://${trimmed}`; + + try { + const parsed = new URL(withHttps); + + if (!parsed.hostname) { + return null; + } + + return withHttps; + } catch { + return null; + } +} + const DNS_CACHE = new LRUCache({ max: 500, ttl: 1000 * 60 * 5, // 5 minutes @@ -19,9 +59,13 @@ function isPrivateIp(ip: string): boolean { /^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i, /^::ffff:192\.168\./i, /^::ffff:169\.254\./i, - /^::1$/, - /^fc00:/i, - /^fe80:/i, + /^::1$/i, + /* ULA fc00::/7 (fc00–fdff first hextet) */ + /^f[cd][0-9a-f]{2}:/i, + /^::ffff:f[cd][0-9a-f]{2}:/i, + /* Link-local fe80::/10 (fe80–febf first hextet) */ + /^fe[89ab][0-9a-f]{2}:/i, + /^::ffff:fe[89ab][0-9a-f]{2}:/i, ]; return privateRanges.some((range) => range.test(ip)); diff --git a/packages/providers/src/lib/chat/chat-webhook/chat-webhook.provider.ts b/packages/providers/src/lib/chat/chat-webhook/chat-webhook.provider.ts index fe2246d7704..3ccb6e1b339 100644 --- a/packages/providers/src/lib/chat/chat-webhook/chat-webhook.provider.ts +++ b/packages/providers/src/lib/chat/chat-webhook/chat-webhook.provider.ts @@ -1,4 +1,5 @@ import { ChatProviderIdEnum } from '@novu/shared'; +import { normalizeOutboundHttpUrl, validateUrlSsrf } from '@novu/shared/utils/ssrf-url-validation'; import { ChannelTypeEnum, ENDPOINT_TYPES, @@ -51,7 +52,20 @@ export class ChatWebhookProvider extends BaseProvider implements IChatProvider { delete data.body.hmacSecretKey; } - const response = await axios.create().post((data?.body?.webhookUrl as string) || endpoint.url, body, { + const targetUrlRaw = (data?.body?.webhookUrl as string) || endpoint.url; + const targetUrl = normalizeOutboundHttpUrl(targetUrlRaw); + + if (!targetUrl) { + throw new Error('Chat webhook URL blocked: Invalid URL format.'); + } + + const ssrfError = await validateUrlSsrf(targetUrl); + + if (ssrfError) { + throw new Error(`Chat webhook URL blocked: ${ssrfError}`); + } + + const response = await axios.create().post(targetUrl, body, { headers: { 'content-type': 'application/json', 'X-Novu-Signature': hmacValue, diff --git a/packages/shared/package.json b/packages/shared/package.json index e98d53739ca..672de6dd1d5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -42,8 +42,16 @@ "require": "./dist/cjs/utils/index.js", "import": "./dist/esm/utils/index.js", "types": "./dist/esm/utils/index.d.js" + }, + "./utils/ssrf-url-validation": { + "require": "./dist/cjs/utils/ssrf-url-validation.js", + "import": "./dist/esm/utils/ssrf-url-validation.js", + "types": "./dist/esm/utils/ssrf-url-validation.d.ts" } }, + "dependencies": { + "lru-cache": "^11.2.4" + }, "devDependencies": { "madge": "^8.0.0", "rimraf": "^3.0.2", diff --git a/packages/shared/src/utils/ssrf-url-validation.ts b/packages/shared/src/utils/ssrf-url-validation.ts new file mode 100644 index 00000000000..4c640fc27a2 --- /dev/null +++ b/packages/shared/src/utils/ssrf-url-validation.ts @@ -0,0 +1,117 @@ +import * as dns from 'node:dns'; +import { LRUCache } from 'lru-cache'; + +/* Keep in sync with libs/application-generic/src/utils/ssrf-url-validation.ts (normalizeOutboundHttpUrl + validateUrlSsrf) */ + +/** + * Resolves a webhook-style URL for outbound HTTP requests. + * Host-only or path-first values (no scheme) are treated as https, matching axios behavior. + */ +export function normalizeOutboundHttpUrl(raw: string): string | null { + const trimmed = raw.trim(); + + if (!trimmed) { + return null; + } + + try { + const parsed = new URL(trimmed); + + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return trimmed; + } + + return null; + } catch { + // Continue: scheme-less host/path (e.g. example.com/hook) + } + + const withHttps = `https://${trimmed}`; + + try { + const parsed = new URL(withHttps); + + if (!parsed.hostname) { + return null; + } + + return withHttps; + } catch { + return null; + } +} + +const DNS_CACHE = new LRUCache({ + max: 500, + ttl: 1000 * 60 * 5, // 5 minutes +}); + +function isPrivateIp(ip: string): boolean { + const privateRanges = [ + /^0\.0\.0\.0$/i, + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[01])\./, + /^192\.168\./, + /^169\.254\./, + /^::ffff:127\./i, + /^::ffff:10\./i, + /^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i, + /^::ffff:192\.168\./i, + /^::ffff:169\.254\./i, + /^::1$/i, + /* ULA fc00::/7 (fc00–fdff first hextet) */ + /^f[cd][0-9a-f]{2}:/i, + /^::ffff:f[cd][0-9a-f]{2}:/i, + /* Link-local fe80::/10 (fe80–febf first hextet) */ + /^fe[89ab][0-9a-f]{2}:/i, + /^::ffff:fe[89ab][0-9a-f]{2}:/i, + ]; + + return privateRanges.some((range) => range.test(ip)); +} + +/** + * Validates that a URL is safe to fetch server-side (http/https only, no private IPs after DNS resolution). + * Returns an error message string if blocked, or null if allowed. + */ +export async function validateUrlSsrf(url: string): Promise { + let parsed: URL; + + try { + parsed = new URL(url); + } catch { + return 'Invalid URL format.'; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return `URL scheme "${parsed.protocol}" is not allowed. Only http and https are permitted.`; + } + + const hostname = parsed.hostname.toLowerCase(); + + const blockedHostnames = ['localhost', 'metadata.google.internal']; + + if (blockedHostnames.includes(hostname)) { + return `Requests to "${hostname}" are not allowed.`; + } + + let addresses = DNS_CACHE.get(hostname); + + if (!addresses) { + try { + addresses = await dns.promises.lookup(hostname, { all: true }); + DNS_CACHE.set(hostname, addresses); + } catch { + return `Unable to resolve hostname "${hostname}".`; + } + } + + for (const { address } of addresses) { + if (isPrivateIp(address)) { + return `Requests to private or reserved IP addresses are not allowed (resolved: ${address}).`; + } + } + + return null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61c869ce93b..424e2946c5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4014,6 +4014,10 @@ importers: version: 5.6.2 packages/shared: + dependencies: + lru-cache: + specifier: ^11.2.4 + version: 11.2.4 devDependencies: madge: specifier: ^8.0.0 @@ -30819,7 +30823,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.2(zod@4.3.5) + better-call: 1.3.2(zod@4.3.6) jose: 6.1.3 kysely: 0.28.14 nanostores: 1.2.0 @@ -44167,7 +44171,7 @@ snapshots: '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.3.2(zod@4.3.5) + better-call: 1.3.2(zod@4.3.6) defu: 6.1.6 jose: 6.1.3 kysely: 0.28.14 @@ -44186,15 +44190,6 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.2(zod@4.3.5): - dependencies: - '@better-auth/utils': 0.3.1 - '@better-fetch/fetch': 1.1.21 - rou3: 0.7.12 - set-cookie-parser: 3.1.0 - optionalDependencies: - zod: 4.3.5 - better-call@1.3.2(zod@4.3.6): dependencies: '@better-auth/utils': 0.3.1 @@ -45820,7 +45815,6 @@ snapshots: ms: 2.1.3 optionalDependencies: supports-color: 8.1.1 - optional: true debug@4.3.1(supports-color@8.1.1): dependencies: @@ -52145,7 +52139,7 @@ snapshots: needle@3.2.0: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) iconv-lite: 0.6.3 sax: 1.5.0 transitivePeerDependencies: @@ -53475,7 +53469,7 @@ snapshots: portfinder@1.0.32: dependencies: async: 2.6.4 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) mkdirp: 0.5.6 transitivePeerDependencies: - supports-color