diff --git a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts b/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts index 3a468791971..13eb1419110 100644 --- a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts @@ -8,7 +8,6 @@ import { IsObject, IsOptional, IsString, - MaxLength, Validate, ValidateNested, ValidatorConstraint, @@ -18,6 +17,8 @@ import { export type { FileRef } from '@novu/framework'; const SIGNAL_TYPES = ['metadata', 'trigger'] as const; +const MAX_INLINE_FILE_BASE64_CHARS = 7_000_000; +const MAX_FILES_PER_MESSAGE = 15; /** * Allowed characters for a metadata signal key. @@ -76,17 +77,25 @@ export class IsValidReplyContent implements ValidatorConstraintInterface { if (fields.length !== 1) return false; if (content.files?.length && !content.markdown) return false; + if ((content.files?.length ?? 0) > MAX_FILES_PER_MESSAGE) return false; for (const file of content.files ?? []) { const sources = [file.data, file.url].filter(Boolean); if (sources.length !== 1) return false; + if (typeof file.data === 'string' && file.data.replace(/\s/g, '').length > MAX_INLINE_FILE_BASE64_CHARS) { + return false; + } } return true; } defaultMessage(): string { - return 'Content must have exactly one of markdown or card. Files only allowed with markdown. Each file needs exactly one of data or url.'; + return ( + 'Content must have exactly one of markdown or card. Files only allowed with markdown. ' + + `At most ${MAX_FILES_PER_MESSAGE} files are allowed. Each file needs exactly one of data or url. ` + + 'Inline data must be 5 MB or smaller.' + ); } } diff --git a/apps/api/src/app/agents/services/chat-sdk.service.spec.ts b/apps/api/src/app/agents/services/chat-sdk.service.spec.ts index 69772c1fb3c..2edf4fe8c47 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.spec.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.spec.ts @@ -1,9 +1,402 @@ +import type { IncomingHttpHeaders } from 'node:http'; import { ChannelTypeEnum, EmailProviderIdEnum } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { ChatSdkService } from './chat-sdk.service'; +function makePinnedResponse({ + status = 200, + statusText = 'OK', + headers = {}, + data = Buffer.from('hello'), +}: { + status?: number; + statusText?: string; + headers?: IncomingHttpHeaders; + data?: Buffer; +} = {}) { + return { status, statusText, headers, data }; +} + describe('ChatSdkService', () => { + function makeService() { + const logger = { + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + info: sinon.stub(), + setContext: sinon.stub(), + }; + + return new ChatSdkService(logger as any, {} as any, {} as any, {} as any, {} as any); + } + + describe('prepareContentForDelivery', () => { + it('should reject card replies with file attachments', async () => { + const service = makeService(); + + try { + await (service as any).prepareContentForDelivery( + { + card: { type: 'card', title: 'Report', children: [] }, + files: [ + { + filename: 'sample.txt', + data: Buffer.from('hello').toString('base64'), + }, + ], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include( + 'File attachments are only supported with string or markdown replies, not cards.' + ); + } + }); + + it('should convert base64 file data to a Buffer before passing content to the chat SDK', async () => { + const service = makeService(); + const result = await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [ + { + filename: 'sample.txt', + mimeType: 'text/plain', + data: Buffer.from('hello').toString('base64'), + }, + ], + }, + 'slack' + ); + + expect(Buffer.isBuffer(result.files[0].data)).to.equal(true); + expect(result.files[0].data.toString()).to.equal('hello'); + expect(result.files[0].filename).to.equal('sample.txt'); + expect(result.files[0].mimeType).to.equal('text/plain'); + }); + + it('should reject non-string file data with a meaningful error', async () => { + const service = makeService(); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [ + { + filename: 'sample.txt', + data: { type: 'Buffer', data: [104, 101, 108, 108, 111] }, + }, + ], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('Invalid file "sample.txt": data must be a base64-encoded string.'); + } + }); + + it('should reject invalid base64 file data with a meaningful error', async () => { + const service = makeService(); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [ + { + filename: 'sample.txt', + data: 'not base64', + }, + ], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('Invalid file "sample.txt": data must be a base64-encoded string.'); + } + }); + + it('should reject inline file data over 5 MB', async () => { + const service = makeService(); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [ + { + filename: 'large.bin', + data: Buffer.alloc(5 * 1024 * 1024 + 1).toString('base64'), + }, + ], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('inline data must be 5 MB or smaller'); + } + }); + + it('should fetch url file data to a Buffer and use response content-type as fallback mimeType', async () => { + const service = makeService(); + sinon.stub(service as any, 'validateFileUrl').resolves(null); + const requestStub = sinon.stub(service as any, 'requestPinnedFileUrl').resolves( + makePinnedResponse({ + headers: { + 'content-type': 'text/plain', + 'content-length': '5', + }, + }) + ); + + const result = await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [ + { + filename: 'sample.txt', + url: 'https://example.com/sample.txt', + }, + ], + }, + 'slack' + ); + + expect(requestStub.calledOnceWith('https://example.com/sample.txt')).to.equal(true); + expect(Buffer.isBuffer(result.files[0].data)).to.equal(true); + expect(result.files[0].data.toString()).to.equal('hello'); + expect(result.files[0].mimeType).to.equal('text/plain'); + expect(result.files[0].url).to.equal(undefined); + }); + + it('should validate redirected file urls before following them', async () => { + const service = makeService(); + const validateStub = sinon + .stub(service as any, 'validateFileUrl') + .onFirstCall() + .resolves(null) + .onSecondCall() + .resolves('Requests to "localhost" are not allowed.'); + const requestStub = sinon.stub(service as any, 'requestPinnedFileUrl').resolves( + makePinnedResponse({ + status: 302, + headers: { + location: 'http://localhost/private.txt', + }, + }) + ); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [{ filename: 'sample.txt', url: 'https://example.com/sample.txt' }], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect(validateStub.callCount).to.equal(2); + expect(requestStub.calledOnceWith('https://example.com/sample.txt')).to.equal(true); + expect((err as Error).message).to.include('Requests to "localhost" are not allowed.'); + } + }); + + it('should reject SSRF-blocked file urls', async () => { + const service = makeService(); + sinon.stub(service as any, 'validateFileUrl').resolves('Requests to "localhost" are not allowed.'); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [{ filename: 'sample.txt', url: 'http://localhost/sample.txt' }], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('Requests to "localhost" are not allowed.'); + } + }); + + it('should reject non-2xx file url responses', async () => { + const service = makeService(); + sinon.stub(service as any, 'validateFileUrl').resolves(null); + sinon + .stub(service as any, 'requestPinnedFileUrl') + .resolves(makePinnedResponse({ status: 404, statusText: 'Not Found' })); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [{ filename: 'missing.txt', url: 'https://example.com/missing.txt' }], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('404 Not Found'); + } + }); + + it('should reject file urls with content-length over the per-file limit', async () => { + const service = makeService(); + sinon.stub(service as any, 'validateFileUrl').resolves(null); + sinon.stub(service as any, 'requestPinnedFileUrl').resolves( + makePinnedResponse({ + headers: { + 'content-length': String(26 * 1024 * 1024), + }, + }) + ); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [{ filename: 'large.bin', url: 'https://example.com/large.bin' }], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('file size exceeds 25 MB'); + } + }); + + it('should reject streamed file url bodies over the per-file limit', async () => { + const service = makeService(); + sinon.stub(service as any, 'validateFileUrl').resolves(null); + sinon + .stub(service as any, 'requestPinnedFileUrl') + .rejects(new Error('Invalid file "large.bin": file size exceeds 25 MB.')); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [{ filename: 'large.bin', url: 'https://example.com/large.bin' }], + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('file size exceeds 25 MB'); + } + }); + + it('should reject more than 15 files per message', async () => { + const service = makeService(); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here are the files', + files: Array.from({ length: 16 }, (_, index) => ({ + filename: `${index}.txt`, + data: Buffer.from('hello').toString('base64'), + })), + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('maximum is 15 files per message'); + } + }); + + it('should reject aggregate attachment size over 50 MB', async () => { + const service = makeService(); + sinon.stub(service as any, 'prepareFileForDelivery').callsFake(async (_file: unknown, index: number) => ({ + filename: `${index}.bin`, + data: Buffer.from('hello'), + size: 5 * 1024 * 1024, + source: 'url', + })); + + try { + await (service as any).prepareContentForDelivery( + { + markdown: 'Here are the files', + files: Array.from({ length: 11 }, (_, index) => ({ + filename: `${index}.bin`, + url: `https://example.com/${index}.bin`, + })), + }, + 'slack' + ); + throw new Error('Expected prepareContentForDelivery to throw'); + } catch (err) { + expect((err as Error).message).to.include('Total attachment size exceeds 50 MB'); + } + }); + + it('should drop files with a warning for email', async () => { + const logger = { + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + info: sinon.stub(), + setContext: sinon.stub(), + }; + const service = new ChatSdkService(logger as any, {} as any, {} as any, {} as any, {} as any); + + const result = await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [{ filename: 'sample.txt', data: Buffer.from('hello').toString('base64') }], + }, + 'email', + 'agent-id' + ); + + expect(result.files).to.equal(undefined); + expect(logger.warn.calledOnce).to.equal(true); + expect(logger.warn.firstCall.args[0]).to.deep.include({ + agentId: 'agent-id', + platform: 'email', + droppedCount: 1, + }); + }); + + it('should drop files with a warning for whatsapp', async () => { + const logger = { + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + info: sinon.stub(), + setContext: sinon.stub(), + }; + const service = new ChatSdkService(logger as any, {} as any, {} as any, {} as any, {} as any); + + const result = await (service as any).prepareContentForDelivery( + { + markdown: 'Here is the file', + files: [{ filename: 'sample.txt', data: Buffer.from('hello').toString('base64') }], + }, + 'whatsapp', + 'agent-id' + ); + + expect(result.files).to.equal(undefined); + expect(logger.warn.calledOnce).to.equal(true); + expect(logger.warn.firstCall.args[0]).to.deep.include({ + agentId: 'agent-id', + platform: 'whatsapp', + droppedCount: 1, + }); + }); + }); + describe('buildSendEmailCallback', () => { it('should skip custom MIME alternatives for unsupported outbound providers', async () => { const logger = { diff --git a/apps/api/src/app/agents/services/chat-sdk.service.ts b/apps/api/src/app/agents/services/chat-sdk.service.ts index 16378d66397..870d50cd980 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -1,5 +1,15 @@ +import * as dns from 'node:dns'; +import * as http from 'node:http'; +import * as https from 'node:https'; import { BadGatewayException, BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; -import { CacheService, decryptCredentials, MailFactory, PinoLogger } from '@novu/application-generic'; +import { + CacheService, + decryptCredentials, + isPrivateIp, + MailFactory, + PinoLogger, + validateUrlSsrf, +} from '@novu/application-generic'; import { IntegrationRepository } from '@novu/dal'; import type { SentMessageInfo } from '@novu/framework'; import { ChannelTypeEnum, EmailProviderIdEnum, type IEmailOptions } from '@novu/shared'; @@ -8,7 +18,7 @@ import { Request as ExpressRequest, Response as ExpressResponse } from 'express' import { LRUCache } from 'lru-cache'; import { AgentEventEnum } from '../dtos/agent-event.enum'; import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import type { ReplyContentDto } from '../dtos/agent-reply-payload.dto'; +import type { FileRef, ReplyContentDto } from '../dtos/agent-reply-payload.dto'; import { esmImport } from '../utils/esm-import'; import { sendWebResponse, toWebRequest } from '../utils/express-to-web-request'; import { AgentConfigResolver, ResolvedAgentConfig } from './agent-config-resolver.service'; @@ -49,6 +59,17 @@ function wrapMsgId(id: string): string { const MAX_CACHED_INSTANCES = 200; const INSTANCE_TTL_MS = 1000 * 60 * 30; +const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; +const MAX_INLINE_FILE_BYTES = 5 * 1024 * 1024; +const MAX_INLINE_AGGREGATE_FILE_BYTES = 5 * 1024 * 1024; +const MAX_FILE_BYTES = 25 * 1024 * 1024; +const MAX_FILES_PER_MESSAGE = 15; +const MAX_AGGREGATE_FILE_BYTES = 50 * 1024 * 1024; +const MAX_INLINE_FILE_BASE64_CHARS = 7_000_000; +const FILE_FETCH_TIMEOUT_MS = 10_000; +const MAX_FILE_FETCH_REDIRECTS = 3; +const SUPPORTED_FILE_PLATFORMS = new Set([AgentPlatformEnum.SLACK, AgentPlatformEnum.TEAMS]); +const UNSUPPORTED_FILE_PLATFORMS = new Set([AgentPlatformEnum.EMAIL, AgentPlatformEnum.WHATSAPP]); // EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS is a deliberate allowlist for providers that preserve custom MIME // alternatives used by Gmail reactions; Braze, Brevo, Mailgun, Mailjet, Mailtrap, Mandrill, Plunk, Postmark, // Resend, SparkPost, and similar providers are excluded until their SDK paths are verified. @@ -77,6 +98,16 @@ interface CachedChat { adapterFingerprint: string; } +type ChatSdkFile = Omit & { data?: Buffer }; +type ChatSdkReplyContent = Omit & { files?: ChatSdkFile[] }; +type MaterializedFile = ChatSdkFile & { size: number; source: 'data' | 'url' }; +type PinnedFileResponse = { + status: number; + statusText: string; + headers: http.IncomingHttpHeaders; + data: Buffer; +}; + @Injectable() export class ChatSdkService implements OnModuleDestroy { private readonly instances: LRUCache; @@ -144,12 +175,13 @@ export class ChatSdkService implements OnModuleDestroy { const { ThreadImpl } = await esmImport('chat'); const adapter = chat.getAdapter(platform); const thread = ThreadImpl.fromJSON(serializedThread, adapter); + const deliveryContent = await this.prepareContentForDelivery(content, platform, agentId); let postPromise: Promise<{ id: string; threadId: string }>; - if (content.card) { - postPromise = thread.post(content.card); + if (deliveryContent.card) { + postPromise = thread.post(deliveryContent.card); } else { - postPromise = thread.post({ markdown: content.markdown ?? '', files: content.files }); + postPromise = thread.post({ markdown: deliveryContent.markdown ?? '', files: deliveryContent.files }); } const sent = await postPromise.catch(toDeliveryError); @@ -174,17 +206,19 @@ export class ChatSdkService implements OnModuleDestroy { throw new BadRequestException(`Platform ${platform} does not support editing messages`); } + const deliveryContent = await this.prepareContentForDelivery(content, platform, agentId); + let editPromise: Promise<{ id: string; threadId: string }>; - if (content.card) { + if (deliveryContent.card) { editPromise = adapter.editMessage( platformThreadId, platformMessageId, - content.card as unknown as AdapterPostableMessage + deliveryContent.card as unknown as AdapterPostableMessage ); } else { editPromise = adapter.editMessage(platformThreadId, platformMessageId, { - markdown: content.markdown ?? '', - files: content.files, + markdown: deliveryContent.markdown ?? '', + files: deliveryContent.files, } as unknown as AdapterPostableMessage); } @@ -193,6 +227,365 @@ export class ChatSdkService implements OnModuleDestroy { return { messageId: edited.id, platformThreadId: edited.threadId }; } + private async prepareContentForDelivery( + content: ReplyContentDto, + platform: string = AgentPlatformEnum.SLACK, + agentId?: string + ): Promise { + if (content.card && content.files?.length) { + throw new BadRequestException({ + error: 'attachment_failed', + message: 'File attachments are only supported with string or markdown replies, not cards.', + }); + } + + if (!content.files?.length) { + return content as ChatSdkReplyContent; + } + + if (UNSUPPORTED_FILE_PLATFORMS.has(platform)) { + this.logger.warn( + { + agentId, + platform, + droppedCount: content.files.length, + }, + 'Dropping outbound agent files because platform does not support attachments' + ); + + const { files: _files, ...withoutFiles } = content; + + return withoutFiles as ChatSdkReplyContent; + } + + if (!SUPPORTED_FILE_PLATFORMS.has(platform)) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `File attachments are not supported on platform "${platform}".`, + }); + } + + if (content.files.length > MAX_FILES_PER_MESSAGE) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Too many attachments: maximum is ${MAX_FILES_PER_MESSAGE} files per message.`, + }); + } + + const files: ChatSdkFile[] = []; + let aggregateSize = 0; + let inlineAggregateSize = 0; + + for (const [index, file] of content.files.entries()) { + const materialized = await this.prepareFileForDelivery(file, index); + aggregateSize += materialized.size; + if (materialized.source === 'data') { + inlineAggregateSize += materialized.size; + } + + if (aggregateSize > MAX_AGGREGATE_FILE_BYTES) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Total attachment size exceeds ${this.formatBytes(MAX_AGGREGATE_FILE_BYTES)}.`, + }); + } + + if (inlineAggregateSize > MAX_INLINE_AGGREGATE_FILE_BYTES) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Total inline attachment size exceeds ${this.formatBytes(MAX_INLINE_AGGREGATE_FILE_BYTES)}. Use URLs for larger files.`, + }); + } + + const { size: _size, source: _source, ...chatSdkFile } = materialized; + files.push(chatSdkFile); + } + + return { + ...content, + files, + }; + } + + private async prepareFileForDelivery(file: FileRef, index: number): Promise { + const data = (file as { data?: unknown }).data; + const url = (file as { url?: unknown }).url; + + if (data !== undefined && data !== null) { + if (typeof data !== 'string') { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, + }); + } + + const buffer = this.decodeBase64FileData(data, file, index); + const { url: _url, ...fileWithoutUrl } = file; + + return { + ...fileWithoutUrl, + data: buffer, + size: buffer.length, + source: 'data', + }; + } + + if (typeof url !== 'string') { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: provide a public HTTP(S) url or base64 data.`, + }); + } + + const fetched = await this.fetchFileUrl(url, file, index); + const { url: _url, ...fileWithoutUrl } = file; + + return { + ...fileWithoutUrl, + data: fetched.data, + mimeType: file.mimeType || fetched.mimeType, + size: fetched.data.length, + source: 'url', + }; + } + + private decodeBase64FileData(data: string, file: FileRef, index: number): Buffer { + const normalized = data.replace(/\s/g, ''); + const remainder = normalized.length % 4; + + if (normalized.length > MAX_INLINE_FILE_BASE64_CHARS) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: inline data must be ${this.formatBytes(MAX_INLINE_FILE_BYTES)} or smaller.`, + }); + } + + if (!normalized || remainder === 1 || !BASE64_REGEX.test(normalized)) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, + }); + } + + const padded = remainder === 0 ? normalized : normalized.padEnd(normalized.length + (4 - remainder), '='); + const buffer = Buffer.from(padded, 'base64'); + + if (buffer.toString('base64').replace(/=+$/, '') !== normalized.replace(/=+$/, '')) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, + }); + } + + if (buffer.length > MAX_INLINE_FILE_BYTES) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: inline data must be ${this.formatBytes(MAX_INLINE_FILE_BYTES)} or smaller.`, + }); + } + + return buffer; + } + + private async fetchFileUrl(url: string, file: FileRef, index: number): Promise<{ data: Buffer; mimeType?: string }> { + const response = await this.fetchValidatedFileUrl(url, file, index); + + if (response.status < 200 || response.status >= 300) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Failed to fetch file ${this.describeFile(file, index)}: ${response.status} ${response.statusText}`, + }); + } + + const contentLength = this.getHeader(response.headers, 'content-length'); + if (contentLength) { + const size = Number(contentLength); + if (Number.isFinite(size) && size > MAX_FILE_BYTES) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, + }); + } + } + + const data = response.data; + const mimeType = this.getHeader(response.headers, 'content-type'); + + return { data, mimeType }; + } + + private async fetchValidatedFileUrl(url: string, file: FileRef, index: number): Promise { + let currentUrl = url; + + for (let redirectCount = 0; redirectCount <= MAX_FILE_FETCH_REDIRECTS; redirectCount += 1) { + const ssrfError = await this.validateFileUrl(currentUrl); + if (ssrfError) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)} url: ${ssrfError}`, + }); + } + + let response: PinnedFileResponse; + try { + response = await this.requestPinnedFileUrl(currentUrl, file, index); + } catch (err) { + if (err instanceof BadRequestException) { + throw err; + } + + const message = err instanceof Error ? err.message : String(err); + throw new BadRequestException({ + error: 'attachment_failed', + message: `Failed to fetch file ${this.describeFile(file, index)}: ${message}`, + }); + } + + if (response.status < 300 || response.status >= 400) { + return response; + } + + const location = this.getHeader(response.headers, 'location'); + if (!location) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Failed to fetch file ${this.describeFile(file, index)}: redirect response missing Location header.`, + }); + } + + currentUrl = new URL(location, currentUrl).toString(); + } + + throw new BadRequestException({ + error: 'attachment_failed', + message: `Failed to fetch file ${this.describeFile(file, index)}: too many redirects.`, + }); + } + + private async validateFileUrl(url: string): Promise { + return validateUrlSsrf(url); + } + + private async requestPinnedFileUrl(url: string, file: FileRef, index: number): Promise { + const parsed = new URL(url); + const address = await this.resolvePublicAddress(parsed, file, index); + const client = parsed.protocol === 'https:' ? https : http; + + return await new Promise((resolve, reject) => { + const request = client.request( + { + protocol: parsed.protocol, + hostname: address.address, + family: address.family, + port: parsed.port || undefined, + path: `${parsed.pathname}${parsed.search}`, + method: 'GET', + headers: { Host: parsed.host }, + servername: parsed.hostname, + timeout: FILE_FETCH_TIMEOUT_MS, + }, + (response) => { + const status = response.statusCode ?? 0; + const statusText = response.statusMessage ?? ''; + + if (status >= 300 && status < 400) { + response.resume(); + resolve({ status, statusText, headers: response.headers, data: Buffer.alloc(0) }); + + return; + } + + const contentLength = this.getHeader(response.headers, 'content-length'); + if (contentLength) { + const size = Number(contentLength); + if (Number.isFinite(size) && size > MAX_FILE_BYTES) { + response.destroy(); + reject( + new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, + }) + ); + + return; + } + } + + const chunks: Buffer[] = []; + let total = 0; + + response.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_FILE_BYTES) { + response.destroy( + new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, + }) + ); + + return; + } + + chunks.push(chunk); + }); + response.on('end', () => + resolve({ status, statusText, headers: response.headers, data: Buffer.concat(chunks, total) }) + ); + response.on('error', reject); + } + ); + + request.on('timeout', () => request.destroy(new Error('Request timed out'))); + request.on('error', reject); + request.end(); + }); + } + + private async resolvePublicAddress(parsed: URL, file: FileRef, index: number): Promise { + let addresses: dns.LookupAddress[]; + try { + addresses = await dns.promises.lookup(parsed.hostname, { all: true }); + } catch { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)} url: Unable to resolve hostname "${parsed.hostname}".`, + }); + } + + if (!addresses.length) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)} url: Unable to resolve hostname "${parsed.hostname}".`, + }); + } + + for (const { address } of addresses) { + if (isPrivateIp(address)) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)} url: Requests to private or reserved IP addresses are not allowed (resolved: ${address}).`, + }); + } + } + + return addresses[0]; + } + + private getHeader(headers: http.IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name.toLowerCase()]; + + return Array.isArray(value) ? value[0] : value; + } + + private describeFile(file: FileRef, index: number): string { + return file.filename ? `"${file.filename}"` : `at index ${index}`; + } + + private formatBytes(bytes: number): string { + return `${Math.floor(bytes / (1024 * 1024))} MB`; + } + async removeReaction( agentId: string, integrationIdentifier: string, diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts index 86b39612473..1463f368adc 100644 --- a/apps/api/src/bootstrap.ts +++ b/apps/api/src/bootstrap.ts @@ -114,7 +114,7 @@ export async function bootstrap( app.use(extendedBodySizeRoutes, bodyParser.json({ limit: '26mb' })); app.use(extendedBodySizeRoutes, bodyParser.urlencoded({ limit: '26mb', extended: true })); - app.use('/v1/agents', bodyParser.json({ verify: agentRawBodyBuffer })); + app.use('/v1/agents', bodyParser.json({ limit: '8mb', verify: agentRawBodyBuffer })); // Add text/plain parser specifically for inbound webhooks (SNS confirmations) app.use( diff --git a/apps/dashboard/public/images/agents/slack-credentials-preview.gif b/apps/dashboard/public/images/agents/slack-credentials-preview.gif new file mode 100644 index 00000000000..1c9ff5d5824 Binary files /dev/null and b/apps/dashboard/public/images/agents/slack-credentials-preview.gif differ diff --git a/apps/dashboard/src/components/agents/slack-setup-guide.tsx b/apps/dashboard/src/components/agents/slack-setup-guide.tsx index cdc85ed4ec4..352f1cabffc 100644 --- a/apps/dashboard/src/components/agents/slack-setup-guide.tsx +++ b/apps/dashboard/src/components/agents/slack-setup-guide.tsx @@ -219,17 +219,8 @@ export function SlackSetupGuide({ description={ { - 'Paste the App ID, Client ID, Client Secret and Signing Secret from your Slack app into the integration. View ' + 'Paste the App Credentials block from your Slack app — the App ID, Client ID, Client Secret and Signing Secret are filled automatically.' } - - setup guide - - . } rightContent={ @@ -273,7 +264,7 @@ export function SlackSetupGuide({ user?.externalId && currentEnvironment?.identifier ? ( { let credentials = provider.credentials; @@ -237,15 +245,20 @@ export function IntegrationSettings({ /> )}
- {providerCredentials.map((credential) => ( - - ))} + {isSlackOnboarding && ( + + )} +
+ {providerCredentials.map((credential) => ( + + ))} +
diff --git a/apps/dashboard/src/components/integrations/components/parse-slack-credentials-block.ts b/apps/dashboard/src/components/integrations/components/parse-slack-credentials-block.ts new file mode 100644 index 00000000000..4b614e94b05 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/parse-slack-credentials-block.ts @@ -0,0 +1,243 @@ +import { CredentialsKeyEnum } from '@novu/shared'; + +export type SlackCredentialField = + | CredentialsKeyEnum.ApplicationId + | CredentialsKeyEnum.ClientId + | CredentialsKeyEnum.SecretKey + | CredentialsKeyEnum.SigningSecret; + +type SlackFieldShape = { + key: SlackCredentialField; + label: string; + /** Aliases Slack has used historically; matched case-insensitively. */ + aliases?: string[]; + /** Validates the parsed value shape. Used for inline confidence hints. */ + matches?: RegExp; +}; + +const SLACK_FIELDS: SlackFieldShape[] = [ + { + key: CredentialsKeyEnum.ApplicationId, + label: 'App ID', + matches: /^A[A-Z0-9]{8,}$/, + }, + { + key: CredentialsKeyEnum.ClientId, + label: 'Client ID', + matches: /^\d+\.\d+$/, + }, + { + key: CredentialsKeyEnum.SecretKey, + label: 'Client Secret', + matches: /^[a-f0-9]{32,}$/i, + }, + { + key: CredentialsKeyEnum.SigningSecret, + label: 'Signing Secret', + matches: /^[a-f0-9]{32,}$/i, + }, +]; + +const NOISE_LINES = new Set([ + 'app credentials', + 'date of app creation', + "you'll need to send this secret along with your client id when making your oauth.v2.access request.", + 'slack signs the requests we send you using this secret. confirm that each request comes from slack by verifying its unique signature.', +]); + +/** + * Slack masks secrets in its UI as a row of bullet glyphs. We need to recognize + * the most common ones so the parser can flag them instead of pushing the dots + * into the form. Covers •, ●, ·, ∙, ◦, ∘, ⚫, ⚪ and `*`. + */ +const MASK_CHAR_REGEX = /^[\u2022\u25CF\u00B7\u2219\u25E6\u2218\u26AB\u26AA*]+$/; + +function isMaskedValue(value: string): boolean { + const stripped = value.replace(/\s+/g, ''); + if (stripped.length < 3) { + return false; + } + + return MASK_CHAR_REGEX.test(stripped); +} + +export type ParsedSlackCredentials = { + values: Partial>; + matched: SlackCredentialField[]; + invalid: SlackCredentialField[]; + /** Labels we recognized but whose value was masked (e.g. `••••••••`). */ + masked: SlackCredentialField[]; + unknownLines: string[]; +}; + +/** + * Parse the "App Credentials" block copied from a Slack app settings page. + * + * The block is a freeform mix of section headers and label/value pairs. Slack's + * format is stable enough to parse with a per-field label regex, but tolerant + * to label aliases, mid-line copy artefacts, and the surrounding marketing copy. + */ +export function parseSlackCredentialsBlock(input: string): ParsedSlackCredentials { + const result: ParsedSlackCredentials = { + values: {}, + matched: [], + invalid: [], + masked: [], + unknownLines: [], + }; + + if (!input.trim()) { + return result; + } + + const lines = input + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + + if (NOISE_LINES.has(line.toLowerCase())) { + continue; + } + + let matchedField: SlackFieldShape | undefined; + let value: string | undefined; + + for (const field of SLACK_FIELDS) { + const labelMatch = matchInlineLabel(line, field); + if (labelMatch !== null) { + matchedField = field; + value = labelMatch; + break; + } + + if (matchesLabel(line, field)) { + matchedField = field; + const consumed = consumeNextValue(lines, index); + value = consumed.value; + if (consumed.consumedIndex !== undefined) { + index = consumed.consumedIndex; + } + break; + } + } + + if (!matchedField) { + if (!isLikelyNoise(line)) { + result.unknownLines.push(line); + } + continue; + } + + if (value === undefined || value.length === 0) { + continue; + } + + if ( + result.values[matchedField.key] !== undefined || + result.masked.includes(matchedField.key) + ) { + // Slack pages don't repeat fields; if they do, prefer the first match + // and skip duplicates so the user's existing value isn't clobbered twice. + continue; + } + + if (isMaskedValue(value)) { + // The user copied while the secret was still hidden. Don't push the + // bullets into the form — surface the field so we can prompt them to + // unmask it in Slack and paste again. + result.masked.push(matchedField.key); + continue; + } + + result.values[matchedField.key] = value; + result.matched.push(matchedField.key); + + if (matchedField.matches && !matchedField.matches.test(value)) { + result.invalid.push(matchedField.key); + } + } + + return result; +} + +export function getSlackFieldDisplayName(key: SlackCredentialField): string { + return SLACK_FIELDS.find((field) => field.key === key)?.label ?? key; +} + +/** Heuristic: pasted text likely is a Slack credentials block when at least 2 fields parse cleanly. */ +export function isLikelySlackCredentialsBlock(input: string): boolean { + if (!input.includes('\n')) { + return false; + } + + const parsed = parseSlackCredentialsBlock(input); + + // Masked values still count: the user clearly copied a Slack credentials + // block, they just forgot to unmask the secrets first. + return parsed.matched.length + parsed.masked.length >= 2; +} + +function matchInlineLabel(line: string, field: SlackFieldShape): string | null { + const labels = [field.label, ...(field.aliases ?? [])]; + + for (const label of labels) { + const inlineRegex = new RegExp(`^${escapeRegExp(label)}\\s*[:=]\\s*(.+)$`, 'i'); + const match = line.match(inlineRegex); + if (match) { + return cleanValue(match[1]); + } + } + + return null; +} + +function matchesLabel(line: string, field: SlackFieldShape): boolean { + const labels = [field.label, ...(field.aliases ?? [])]; + const normalized = line.replace(/:\s*$/, '').toLowerCase(); + + return labels.some((label) => normalized === label.replace(/:\s*$/, '').toLowerCase()); +} + +function consumeNextValue( + lines: string[], + currentIndex: number +): { value: string | undefined; consumedIndex?: number } { + for (let lookahead = currentIndex + 1; lookahead < lines.length; lookahead += 1) { + const candidate = lines[lookahead]; + + if (NOISE_LINES.has(candidate.toLowerCase()) || isLikelyNoise(candidate)) { + continue; + } + + if (looksLikeFieldLabel(candidate)) { + return { value: undefined }; + } + + return { value: cleanValue(candidate), consumedIndex: lookahead }; + } + + return { value: undefined }; +} + +function looksLikeFieldLabel(line: string): boolean { + return SLACK_FIELDS.some((field) => matchesLabel(line, field) || matchInlineLabel(line, field) !== null); +} + +function isLikelyNoise(line: string): boolean { + if (line.length > 200) return true; + if (/^[a-z][a-z\s]+:$/i.test(line)) return true; + + return false; +} + +function cleanValue(raw: string): string { + return raw.replace(/^["']|["']$/g, '').trim(); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/apps/dashboard/src/components/integrations/components/slack-credentials-paste.tsx b/apps/dashboard/src/components/integrations/components/slack-credentials-paste.tsx new file mode 100644 index 00000000000..85a734970e2 --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/slack-credentials-paste.tsx @@ -0,0 +1,378 @@ +import { CredentialsKeyEnum } from '@novu/shared'; +import { type ClipboardEvent, useCallback, useMemo, useRef, useState } from 'react'; +import { type Control, type UseFormSetValue, useWatch } from 'react-hook-form'; +import { RiCheckLine, RiClipboardLine, RiCloseLine, RiEyeOffLine, RiInformationLine } from 'react-icons/ri'; +import { Button } from '@/components/primitives/button'; +import { Textarea } from '@/components/primitives/textarea'; +import { cn } from '@/utils/ui'; +import type { IntegrationFormData } from '../types'; +import { + getSlackFieldDisplayName, + isLikelySlackCredentialsBlock, + type ParsedSlackCredentials, + parseSlackCredentialsBlock, + type SlackCredentialField, +} from './parse-slack-credentials-block'; + +const SLACK_FIELDS: SlackCredentialField[] = [ + CredentialsKeyEnum.ApplicationId, + CredentialsKeyEnum.ClientId, + CredentialsKeyEnum.SecretKey, + CredentialsKeyEnum.SigningSecret, +]; + +const PREVIEW_GIF_SRC = '/images/agents/slack-credentials-preview.gif'; + +type ApplyOutcome = { + filled: SlackCredentialField[]; + overwritten: SlackCredentialField[]; + invalid: SlackCredentialField[]; + /** Fields whose Slack value was still masked behind dots when pasted. */ + masked: SlackCredentialField[]; + unknownLines: string[]; +}; + +type SlackCredentialsPasteProps = { + control: Control; + setValue: UseFormSetValue; + isReadOnly?: boolean; +}; + +/** + * Smart-paste affordance for the Slack agent onboarding credentials form. + * + * Renders an inline tip card that recognizes the freeform "App Credentials" + * block from Slack's app settings page and routes the parsed fields back into + * the existing react-hook-form state. Clicking the card opens a paste box + * below; pasting straight into any individual credential field also works + * thanks to {@link useSlackCredentialsPasteFallback}. + */ +export function SlackCredentialsPaste({ control, setValue, isReadOnly }: SlackCredentialsPasteProps) { + const credentials = useWatch({ control, name: 'credentials' }); + const [outcome, setOutcome] = useState(null); + const [draft, setDraft] = useState(''); + const [isExpanded, setIsExpanded] = useState(false); + const [isPreviewLoaded, setIsPreviewLoaded] = useState(false); + const textareaRef = useRef(null); + + // Synchronously mark the preview as loaded if the browser already has it + // cached when the mounts (the `onLoad` event would otherwise race). + const handlePreviewRef = useCallback((node: HTMLImageElement | null) => { + if (node?.complete && node.naturalWidth > 0) { + setIsPreviewLoaded(true); + } + }, []); + + const handlePreviewLoaded = useCallback(() => { + setIsPreviewLoaded(true); + }, []); + + const apply = useCallback( + (parsed: ParsedSlackCredentials): ApplyOutcome => { + const filled: SlackCredentialField[] = []; + const overwritten: SlackCredentialField[] = []; + + for (const key of parsed.matched) { + const value = parsed.values[key]; + if (value === undefined) continue; + + const previous = credentials?.[key]; + if (previous && previous !== value) { + overwritten.push(key); + } + + setValue(`credentials.${key}`, value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + filled.push(key); + } + + return { + filled, + overwritten, + invalid: parsed.invalid, + masked: parsed.masked, + unknownLines: parsed.unknownLines, + }; + }, + [credentials, setValue] + ); + + const handleParse = useCallback( + (text: string) => { + const parsed = parseSlackCredentialsBlock(text); + + if (parsed.matched.length === 0 && parsed.masked.length === 0) { + setOutcome({ + filled: [], + overwritten: [], + invalid: [], + masked: [], + unknownLines: parsed.unknownLines, + }); + + return; + } + + const result = apply(parsed); + setOutcome(result); + setDraft(''); + // Keep the paste box open when there are masked fields so the user can + // re-paste after unmasking; collapse it on a clean fill. + setIsExpanded(parsed.masked.length > 0); + }, + [apply] + ); + + const handlePaste = useCallback( + (event: ClipboardEvent) => { + const text = event.clipboardData.getData('text/plain'); + if (!isLikelySlackCredentialsBlock(text)) { + return; + } + + event.preventDefault(); + handleParse(text); + }, + [handleParse] + ); + + const handleManualParse = useCallback(() => { + handleParse(draft); + }, [draft, handleParse]); + + const dismiss = useCallback(() => { + setOutcome(null); + }, []); + + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => { + const next = !prev; + if (next) { + requestAnimationFrame(() => textareaRef.current?.focus()); + } + + return next; + }); + }, []); + + if (isReadOnly) { + return null; + } + + return ( +
+ + + {isExpanded && ( +
+