fix: contentId header injection fixed and separte unit test SESService added to verify MIME boundaries
This commit is contained in:
@@ -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: '[email protected]' },
|
||||||
|
to: ['[email protected]'],
|
||||||
|
content: { subject: 'Test Subject', html: '<p>Hello world</p>' },
|
||||||
|
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: '[email protected]' },
|
||||||
|
to: ['[email protected]'],
|
||||||
|
content: { subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>' },
|
||||||
|
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: '[email protected]' },
|
||||||
|
to: ['[email protected]'],
|
||||||
|
content: { subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>' },
|
||||||
|
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}--`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -380,9 +380,18 @@ export const ActionSchemas = {
|
|||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
content: z.string().min(1), // Base64 encoded file content
|
content: z.string().min(1), // Base64 encoded file content
|
||||||
contentType: z.string().min(1).max(255),
|
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'),
|
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
|
.max(10) // Maximum 10 attachments per email
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user