Skip to content

Commit e99107b

Browse files
fix(audit): mailer attachment validation + policy guard (#3392)
* fix(mailer): add attachment validation (#3390) Adds validateAttachments() called in sendMail() before provider dispatch. Enforces non-empty string filename, required content, and 25 MB size cap. Adds 4 unit tests covering all rejection paths and the happy path. * fix(policy): defensive guard on req.route + JSDoc (#3391) - organizations.policy: return false early when req.route?.path is absent instead of falling through with an empty string - policy.js deriveSubjectType: document the first-match-wins non-overlapping constraint on registered path subjects - config.js filterByActivation: note that changing 'activated' requires restart * test(mailer,policy): improve coverage for attachment validation and route guard (#3390 #3391)
1 parent 92241aa commit e99107b

6 files changed

Lines changed: 157 additions & 1 deletion

File tree

lib/helpers/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ const extractModuleName = (filePath) => {
121121
* Core modules are never filtered out.
122122
* Missing `activated` key defaults to active (true).
123123
*
124+
* NOTE: Changing the `activated` flag for a module in the environment config requires
125+
* a full application restart to take effect — the file list is resolved at startup
126+
* and is not re-evaluated at runtime.
127+
*
124128
* @param {string[]} files - array of file paths
125129
* @param {object} config - merged configuration object
126130
* @returns {string[]} filtered file paths

lib/helpers/mailer/index.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@ const getProvider = () => {
3737
*/
3838
const isConfigured = () => !!(config.mailer && config.mailer.from && !String(config.mailer.from).startsWith('DEVKIT_NODE_'));
3939

40+
/**
41+
* @desc Validate attachment list before sending.
42+
* Throws if any attachment is malformed or exceeds the 25 MB size limit.
43+
* @param {Array} [attachments] - Optional array of attachment objects
44+
* @throws {Error} If any attachment is invalid
45+
*/
46+
const validateAttachments = (attachments) => {
47+
if (!Array.isArray(attachments)) return;
48+
const MAX_SIZE = 25 * 1024 * 1024; // 25 MB
49+
for (const att of attachments) {
50+
if (!att.filename || typeof att.filename !== 'string') {
51+
throw new Error('Attachment filename must be a non-empty string');
52+
}
53+
if (!att.content) {
54+
throw new Error('Attachment content is required');
55+
}
56+
const size = typeof att.content === 'string'
57+
? Buffer.byteLength(att.content)
58+
: att.content.length;
59+
if (size > MAX_SIZE) {
60+
throw new Error(`Attachment "${att.filename}" exceeds 25 MB limit`);
61+
}
62+
}
63+
};
64+
4065
/**
4166
* @desc Send an email using a handlebars template
4267
* @param {Object} mail - Mail configuration
@@ -51,6 +76,7 @@ const isConfigured = () => !!(config.mailer && config.mailer.from && !String(con
5176
*/
5277
const sendMail = async (mail) => {
5378
if (!isConfigured()) return null;
79+
validateAttachments(mail.attachments);
5480
// Sanitize template name to prevent path traversal
5581
const sanitizedTemplate = path.basename(mail.template);
5682
const file = await files.readFile(path.resolve(`./config/templates/${sanitizedTemplate}.html`));

lib/helpers/mailer/tests/mailer.unit.tests.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,54 @@ describe('mailer index with resend provider unit tests:', () => {
105105

106106
expect(result).toBeNull();
107107
});
108+
109+
test('should throw when attachment is missing content', async () => {
110+
await expect(
111+
mailer.sendMail({
112+
to: 'user@example.com',
113+
subject: 'Test',
114+
template: 'welcome',
115+
params: { name: 'Bob' },
116+
attachments: [{ filename: 'file.txt' }],
117+
}),
118+
).rejects.toThrow('Attachment content is required');
119+
});
120+
121+
test('should throw when attachment exceeds 25 MB', async () => {
122+
const largeContent = Buffer.alloc(26 * 1024 * 1024); // 26 MB
123+
await expect(
124+
mailer.sendMail({
125+
to: 'user@example.com',
126+
subject: 'Test',
127+
template: 'welcome',
128+
params: { name: 'Bob' },
129+
attachments: [{ filename: 'large.bin', content: largeContent }],
130+
}),
131+
).rejects.toThrow('exceeds 25 MB limit');
132+
});
133+
134+
test('should throw when attachment filename is not a string', async () => {
135+
await expect(
136+
mailer.sendMail({
137+
to: 'user@example.com',
138+
subject: 'Test',
139+
template: 'welcome',
140+
params: { name: 'Bob' },
141+
attachments: [{ filename: 123, content: 'data' }],
142+
}),
143+
).rejects.toThrow('Attachment filename must be a non-empty string');
144+
});
145+
146+
test('should not throw for a valid attachment', async () => {
147+
mockSend.mockResolvedValue({ id: 'ok', accepted: ['user@example.com'], rejected: [] });
148+
await expect(
149+
mailer.sendMail({
150+
to: 'user@example.com',
151+
subject: 'Test',
152+
template: 'welcome',
153+
params: { name: 'Bob' },
154+
attachments: [{ filename: 'doc.pdf', content: 'PDF content here' }],
155+
}),
156+
).resolves.not.toThrow();
157+
});
108158
});

lib/middlewares/policy.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ const isAllowed = async (req, res, next) => {
210210
* Iterates the pathSubjectRegistry populated by module policy files.
211211
* Each entry's routeMatch can be an exact string, or a function(routePath) => boolean.
212212
* First match wins.
213+
*
214+
* IMPORTANT: registered route paths must be non-overlapping (or ordered from most-specific
215+
* to least-specific), because resolution is first-match-wins. If two entries could match the
216+
* same path, only the first registered entry will ever be used.
213217
* @param {string} routePath - Express route path (e.g. '/api/tasks' or '/api/users/me')
214218
* @returns {string|null} CASL subject type or null if not mappable
215219
*/

modules/organizations/policies/organizations.policy.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ export function organizationSubjectRegistration({ registerDocumentSubject, regis
1616
// Guard: only resolve req.organization as an Organization subject on actual organization routes.
1717
// Other modules (billing, tasks, etc.) also set req.organization but authorize via their own subjects.
1818
registerDocumentSubject('organization', 'Organization', (req) => {
19-
const p = req.route?.path || '';
19+
if (!req.route?.path) {
20+
return false;
21+
}
22+
const p = req.route.path;
2023
return p.startsWith('/api/organizations') || p.startsWith('/api/admin/organizations');
2124
});
2225
registerPathSubject((p) => p.startsWith('/api/admin/organizations'), 'Organization');
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Unit tests for organizationSubjectRegistration policy guard.
3+
*/
4+
import { describe, test, expect, jest, beforeEach } from '@jest/globals';
5+
6+
import { organizationSubjectRegistration } from '../policies/organizations.policy.js';
7+
8+
/**
9+
* Build a mock subject-registration registry that captures registered resolvers.
10+
* @returns {{ registerDocumentSubject: jest.Mock, registerPathSubject: jest.Mock, _documentResolvers: Map }}
11+
*/
12+
function mockRegistry() {
13+
const documentResolvers = new Map();
14+
return {
15+
registerDocumentSubject: jest.fn((prop, type, guard) => {
16+
documentResolvers.set(prop, { type, guard });
17+
}),
18+
registerPathSubject: jest.fn(),
19+
_documentResolvers: documentResolvers,
20+
};
21+
}
22+
23+
describe('organizationSubjectRegistration policy unit tests:', () => {
24+
test('should register membershipDoc, organization, and path subjects', () => {
25+
const registry = mockRegistry();
26+
organizationSubjectRegistration(registry);
27+
28+
expect(registry.registerDocumentSubject).toHaveBeenCalledWith('membershipDoc', 'Membership');
29+
expect(registry.registerDocumentSubject).toHaveBeenCalledWith('organization', 'Organization', expect.any(Function));
30+
expect(registry.registerPathSubject).toHaveBeenCalledTimes(4);
31+
});
32+
33+
describe('organization document subject guard:', () => {
34+
let guard;
35+
36+
beforeEach(() => {
37+
const registry = mockRegistry();
38+
organizationSubjectRegistration(registry);
39+
guard = registry._documentResolvers.get('organization').guard;
40+
});
41+
42+
test('should return false when req.route is undefined', () => {
43+
expect(guard({ route: undefined })).toBe(false);
44+
});
45+
46+
test('should return false when req.route.path is undefined', () => {
47+
expect(guard({ route: {} })).toBe(false);
48+
});
49+
50+
test('should return false when req.route.path is an empty string', () => {
51+
expect(guard({ route: { path: '' } })).toBe(false);
52+
});
53+
54+
test('should return true for /api/organizations paths', () => {
55+
expect(guard({ route: { path: '/api/organizations' } })).toBe(true);
56+
expect(guard({ route: { path: '/api/organizations/:id' } })).toBe(true);
57+
});
58+
59+
test('should return true for /api/admin/organizations paths', () => {
60+
expect(guard({ route: { path: '/api/admin/organizations' } })).toBe(true);
61+
expect(guard({ route: { path: '/api/admin/organizations/:id' } })).toBe(true);
62+
});
63+
64+
test('should return false for unrelated paths (e.g. billing routes)', () => {
65+
expect(guard({ route: { path: '/api/billing/plans' } })).toBe(false);
66+
expect(guard({ route: { path: '/api/tasks' } })).toBe(false);
67+
});
68+
});
69+
});

0 commit comments

Comments
 (0)