From 08e5c0d930f1db92b754a018ea08900f93032c64 Mon Sep 17 00:00:00 2001 From: Shuvadipta Das Date: Mon, 16 Feb 2026 18:27:32 +0530 Subject: [PATCH] fix: contentId header injection fixed and separte unit test SESService added to verify MIME boundaries --- .../src/services/__tests__/SESService.test.ts | 174 ++++++++++++++++++ packages/shared/src/schemas/index.ts | 13 +- 2 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/services/__tests__/SESService.test.ts diff --git a/apps/api/src/services/__tests__/SESService.test.ts b/apps/api/src/services/__tests__/SESService.test.ts new file mode 100644 index 0000000..014e7ad --- /dev/null +++ b/apps/api/src/services/__tests__/SESService.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { sendRawEmail } from '../SESService'; + +// Mock with both paths to catch resolution variations +vi.mock('../../app/constants.js', () => ({ + AWS_SES_ACCESS_KEY_ID: 'test-key-id', + AWS_SES_REGION: 'us-east-1', + AWS_SES_SECRET_ACCESS_KEY: 'test-secret', + DASHBOARD_URI: 'http://localhost:3000', + SES_CONFIGURATION_SET: 'test-config-set', + SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set', + TRACKING_TOGGLE_ENABLED: true, +})); + + + +// Mock the SES client +vi.mock('@aws-sdk/client-ses', () => { + const SESMock = vi.fn(); + SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({ MessageId: 'test-message-id' }); + return { SES: SESMock }; +}); + +describe('SESService', () => { + let sesInstance: any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should correctly structure MIME boundaries for mixed content (attachments)', async () => { + // Import the exported instance to verify calls + const { ses } = await import('../SESService'); + + const params = { + from: { name: 'Sender', email: 'sender@example.com' }, + to: ['recipient@example.com'], + content: { subject: 'Test Subject', html: '

Hello world

' }, + attachments: [ + { + filename: 'test.txt', + content: 'SGVsbG8=', // Hello + contentType: 'text/plain', + disposition: 'attachment' as const, + }, + ], + }; + + await sendRawEmail(params); + + expect(ses.sendRawEmail).toHaveBeenCalled(); + const callArgs = (ses.sendRawEmail as any).mock.calls[0][0]; + const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data); + + // Verify boundary hierarchy: Mixed -> Alternative + // Explicitly check that "multipart/mixed" is the root content type (first occurrence) + expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s); + + // Check for presence of alternative boundary nested inside + expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/); + + // Verify distinct boundaries + const mixedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/); + const mixedBoundary = mixedBoundaryMatch ? mixedBoundaryMatch[1] : ''; + + expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/alternative`); + expect(rawMessage).toContain(`--${mixedBoundary}--`); + }); + + it('should correctly structure MIME boundaries for related content (inline images)', async () => { + const { ses } = await import('../SESService'); + + const params = { + from: { name: 'Sender', email: 'sender@example.com' }, + to: ['recipient@example.com'], + content: { subject: 'Test Subject', html: '

Hello world

' }, + attachments: [ + { + filename: 'image.png', + content: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + contentType: 'image/png', + contentId: 'image1', + disposition: 'inline' as const, + }, + ], + }; + + await sendRawEmail(params); + + const callArgs = (ses.sendRawEmail as any).mock.calls[0][0]; + const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data); + + // Verify boundary hierarchy: Related -> Alternative + expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/related; boundary="([^"]+)"/s); + + const relatedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/); + const relatedBoundary = relatedBoundaryMatch ? relatedBoundaryMatch[1] : ''; + + // Check that related part contains alternative part + expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`); + // And also contains the inline attachment + expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`); + expect(rawMessage).toContain(`--${relatedBoundary}--`); + }); + + it('should correctly nest mixed > related > alternative boundaries', async () => { + const { ses } = await import('../SESService'); + + const params = { + from: { name: 'Sender', email: 'sender@example.com' }, + to: ['recipient@example.com'], + content: { subject: 'Test Subject', html: '

Hello world

' }, + attachments: [ + { + filename: 'test.txt', + content: 'SGVsbG8=', + contentType: 'text/plain', + disposition: 'attachment' as const, + }, + { + filename: 'image.png', + content: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + contentType: 'image/png', + contentId: 'image1', + disposition: 'inline' as const, + }, + ], + }; + + await sendRawEmail(params); + + const callArgs = (ses.sendRawEmail as any).mock.calls[0][0]; + const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data); + + // Root should be mixed + expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s); + + // Find the mixed boundary + const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/); + const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED'; + + // Within mixed, we should find related + // The code structure: + // --mixed + // Content-Type: multipart/related; boundary="related" + // --related + // Content-Type: multipart/alternative; boundary="alt" + + // Check hierarchy via regex matching structure + // Check that related is defined inside mixed + expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`); + + // Find related boundary + const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/); + const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED'; + + // Check that alternative is defined inside related + expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`); + + const altMatch = rawMessage.match(/Content-Type: multipart\/alternative; boundary="([^"]+)"/); + const altBoundary = altMatch ? altMatch[1] : 'NOT_FOUND_ALT'; + + // Verify closing boundaries existence and order implies nesting + // The end of string should look like: + // --related-- + // --mixed + // ...attachment... + // --mixed-- + + expect(rawMessage).toContain(`--${altBoundary}--`); + expect(rawMessage).toContain(`--${relatedBoundary}--`); + expect(rawMessage).toContain(`--${mixedBoundary}--`); + }); +}); diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index b469914..baa0bd3 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -380,9 +380,18 @@ export const ActionSchemas = { filename: z.string().min(1).max(255), content: z.string().min(1), // Base64 encoded file content contentType: z.string().min(1).max(255), - contentId: z.string().min(1).max(255).optional(), + contentId: z + .string() + .min(1) + .max(255) + .regex(/^[^<>\r\n]+$/, 'Content ID cannot contain <, >, \\r, or \\n') + .optional(), disposition: z.enum(['attachment', 'inline']).default('attachment'), - }), + }) + .refine(data => data.disposition !== 'inline' || !!data.contentId, { + message: 'Content ID is required when disposition is inline', + path: ['contentId'], + }), ) .max(10) // Maximum 10 attachments per email .optional(),