@araminix/platform 1.0.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +73 -1
  2. package/dist/src/Platform.d.ts +12 -1
  3. package/dist/src/Platform.d.ts.map +1 -1
  4. package/dist/src/Platform.js +217 -77
  5. package/dist/src/Platform.js.map +1 -1
  6. package/dist/src/agent/AgentController.d.ts +24 -3
  7. package/dist/src/agent/AgentController.d.ts.map +1 -1
  8. package/dist/src/agent/AgentController.js +168 -60
  9. package/dist/src/agent/AgentController.js.map +1 -1
  10. package/dist/src/agent/worker.js +5 -0
  11. package/dist/src/agent/worker.js.map +1 -1
  12. package/dist/src/executor/Instance.d.ts +9 -3
  13. package/dist/src/executor/Instance.d.ts.map +1 -1
  14. package/dist/src/executor/Instance.js +148 -36
  15. package/dist/src/executor/Instance.js.map +1 -1
  16. package/dist/src/server/AuthProvider.d.ts +70 -1
  17. package/dist/src/server/AuthProvider.d.ts.map +1 -1
  18. package/dist/src/server/AuthProvider.js +12 -1
  19. package/dist/src/server/AuthProvider.js.map +1 -1
  20. package/dist/src/server/Endpoint.d.ts +30 -1
  21. package/dist/src/server/Endpoint.d.ts.map +1 -1
  22. package/dist/src/server/Endpoint.js +467 -72
  23. package/dist/src/server/Endpoint.js.map +1 -1
  24. package/dist/src/server/index.d.ts +1 -0
  25. package/dist/src/server/index.d.ts.map +1 -1
  26. package/dist/src/server/index.js +1 -0
  27. package/dist/src/server/index.js.map +1 -1
  28. package/dist/src/utils/log_middleware.d.ts.map +1 -1
  29. package/dist/src/utils/log_middleware.js +2 -1
  30. package/dist/src/utils/log_middleware.js.map +1 -1
  31. package/dist/src/utils/minioService.d.ts +1 -0
  32. package/dist/src/utils/minioService.d.ts.map +1 -1
  33. package/dist/src/utils/minioService.js +6 -3
  34. package/dist/src/utils/minioService.js.map +1 -1
  35. package/dist/tests/agentWorker.test.d.ts +2 -0
  36. package/dist/tests/agentWorker.test.d.ts.map +1 -0
  37. package/dist/tests/agentWorker.test.js +225 -0
  38. package/dist/tests/agentWorker.test.js.map +1 -0
  39. package/dist/tests/authProvider.test.js +15 -0
  40. package/dist/tests/authProvider.test.js.map +1 -1
  41. package/dist/tests/endpointSecurity.test.d.ts +2 -0
  42. package/dist/tests/endpointSecurity.test.d.ts.map +1 -0
  43. package/dist/tests/endpointSecurity.test.js +1007 -0
  44. package/dist/tests/endpointSecurity.test.js.map +1 -0
  45. package/dist/tests/logMiddleware.test.js +50 -0
  46. package/dist/tests/logMiddleware.test.js.map +1 -1
  47. package/dist/tests/minioService.test.js +178 -1
  48. package/dist/tests/minioService.test.js.map +1 -1
  49. package/dist/tests/platform.test.js +245 -0
  50. package/dist/tests/platform.test.js.map +1 -1
  51. package/dist/tests/unhandledRejections.test.d.ts +2 -0
  52. package/dist/tests/unhandledRejections.test.d.ts.map +1 -0
  53. package/dist/tests/unhandledRejections.test.js +204 -0
  54. package/dist/tests/unhandledRejections.test.js.map +1 -0
  55. package/dist/tests/uploadSecurity.test.d.ts +2 -0
  56. package/dist/tests/uploadSecurity.test.d.ts.map +1 -0
  57. package/dist/tests/uploadSecurity.test.js +219 -0
  58. package/dist/tests/uploadSecurity.test.js.map +1 -0
  59. package/package.json +4 -3
@@ -2,20 +2,36 @@ import { logger, trimSlash, uuid, wrap, WsCloseCodes } from '@araminix/lib';
2
2
  import axios from 'axios';
3
3
  import cookieParser from 'cookie-parser';
4
4
  import expressCore from 'express';
5
+ import { ipKeyGenerator, rateLimit } from 'express-rate-limit';
5
6
  import expressWs from 'express-ws';
6
7
  import fs from 'fs';
7
8
  import multer from 'multer';
8
9
  import path from 'path';
9
- import { logMiddleware, MinioService } from '../utils/index.js';
10
+ import { logMiddleware, MimeToExtension, MinioService } from '../utils/index.js';
10
11
  import { GoogleData } from './AuthProvider.js';
11
12
  import EndpointController from './EndpointController.js';
12
13
  function validateToken(req, res, next) {
13
14
  if (!req.token || !req.info) {
14
15
  res.status(401).send({ message: 'Unauthorized' }).end();
16
+ return;
15
17
  }
16
- else {
17
- next();
18
+ const isMutation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase());
19
+ const isCookieAuth = Boolean(req.signedCookies?.token && !req.header('x-token'));
20
+ if (isMutation && isCookieAuth) {
21
+ const contentType = req.headers['content-type'];
22
+ const hasJson = Boolean(req.is('application/json') || (typeof contentType === 'string' && contentType.toLowerCase().includes('application/json')));
23
+ const hasCustomHeader = Boolean(req.header('x-requested-with') || req.header('x-csrf-token'));
24
+ if (!hasJson && !hasCustomHeader) {
25
+ res.status(403)
26
+ .json({
27
+ error: 'Forbidden: Content-Type: application/json or custom header (e.g. X-Requested-With) required for cookie-authenticated requests',
28
+ message: 'Forbidden',
29
+ })
30
+ .end();
31
+ return;
32
+ }
18
33
  }
34
+ next();
19
35
  }
20
36
  export default class Endpoint {
21
37
  authProvider;
@@ -26,12 +42,18 @@ export default class Endpoint {
26
42
  sessionTtl;
27
43
  uploadDir;
28
44
  minio;
45
+ maxUploadBytes;
46
+ uploadAllowlist;
47
+ googleClientIds;
48
+ allowedOrigins;
49
+ authRateLimit;
50
+ authRateLimiter;
29
51
  express;
30
52
  server;
31
53
  connections = new Map();
32
54
  endpoints = new Map();
33
55
  COOKIE_OPTIONS;
34
- constructor({ authProvider, port = 3000, mount, version, cookieSecret = uuid(), sessionTtl = 3600 * 24, uploadDir, minio = undefined }) {
56
+ constructor({ authProvider, port = 3000, mount, version, cookieSecret = uuid(), sessionTtl = 3600 * 24, uploadDir, minio = undefined, cookieOptions, maxUploadBytes = 20 * 1024 * 1024, uploadAllowlist, googleClientIds, allowedOrigins, authRateLimit, }) {
35
57
  this.authProvider = authProvider;
36
58
  this.port = port;
37
59
  this.mount = mount ? `/${trimSlash(mount)}` : '';
@@ -40,10 +62,78 @@ export default class Endpoint {
40
62
  this.sessionTtl = sessionTtl;
41
63
  this.uploadDir = uploadDir;
42
64
  this.minio = minio;
43
- this.COOKIE_OPTIONS = { signed: true, httpOnly: false, maxAge: this.sessionTtl * 1000 };
44
- this.express = expressWs(expressCore()).app;
65
+ this.maxUploadBytes = maxUploadBytes;
66
+ this.uploadAllowlist = uploadAllowlist;
67
+ this.googleClientIds = googleClientIds;
68
+ this.allowedOrigins = allowedOrigins;
69
+ this.authRateLimit = authRateLimit;
70
+ const rateLimitEnabled = this.authRateLimit !== false;
71
+ if (rateLimitEnabled) {
72
+ const customConfig = typeof this.authRateLimit === 'object' ? this.authRateLimit : {};
73
+ this.authRateLimiter = rateLimit({
74
+ windowMs: customConfig.windowMs ?? 15 * 60 * 1000,
75
+ limit: customConfig.limit ?? customConfig.max ?? 100,
76
+ standardHeaders: true,
77
+ legacyHeaders: false,
78
+ validate: { xForwardedForHeader: false, default: false },
79
+ message: customConfig.message ?? {
80
+ message: 'Too many authentication attempts, please try again later.',
81
+ error: 'Too many requests',
82
+ },
83
+ keyGenerator: customConfig.keyGenerator ??
84
+ ((req) => {
85
+ const rawIp = req.ip || req.socket?.remoteAddress || req.headers['x-forwarded-for'] || '127.0.0.1';
86
+ const ip = ipKeyGenerator(typeof rawIp === 'string' ? rawIp : Array.isArray(rawIp) ? rawIp[0] : '127.0.0.1');
87
+ const account = req.body?.accountName || req.body?.username || req.body?.email || req.body?.account || req.body?.old_token || '';
88
+ return account ? `${ip}:${account}` : ip;
89
+ }),
90
+ ...customConfig,
91
+ });
92
+ }
93
+ else {
94
+ this.authRateLimiter = (_req, _res, next) => next();
95
+ }
96
+ this.COOKIE_OPTIONS = {
97
+ signed: true,
98
+ httpOnly: true,
99
+ secure: true,
100
+ sameSite: 'lax',
101
+ path: '/',
102
+ maxAge: this.sessionTtl * 1000,
103
+ ...cookieOptions,
104
+ };
105
+ const wsOptions = {
106
+ verifyClient: (info, cb) => {
107
+ const origin = info.origin || info.req?.headers?.origin || info.req?.headers?.['sec-websocket-origin'];
108
+ const allowed = this.checkOrigin(origin);
109
+ if (allowed) {
110
+ return cb(true);
111
+ }
112
+ return cb(false, 403, 'Forbidden');
113
+ },
114
+ };
115
+ this.express = expressWs(expressCore(), undefined, { wsOptions }).app;
45
116
  this.init();
46
117
  }
118
+ checkOrigin(origin) {
119
+ if (!origin) {
120
+ return true;
121
+ }
122
+ if (!this.allowedOrigins || this.allowedOrigins.length === 0) {
123
+ return false;
124
+ }
125
+ if (this.allowedOrigins.includes('*')) {
126
+ return true;
127
+ }
128
+ const normalized = origin.trim().toLowerCase().replace(/\/+$/, '');
129
+ return this.allowedOrigins.some((allowed) => {
130
+ return allowed.trim().toLowerCase().replace(/\/+$/, '') === normalized;
131
+ });
132
+ }
133
+ isOriginAllowed(req) {
134
+ const origin = req.headers?.origin || req.headers?.['sec-websocket-origin'];
135
+ return this.checkOrigin(origin);
136
+ }
47
137
  init() {
48
138
  this.express.use(cookieParser(this.cookieSecret));
49
139
  this.express.use(expressCore.json());
@@ -100,47 +190,105 @@ export default class Endpoint {
100
190
  });
101
191
  }
102
192
  initWs() {
193
+ /**
194
+ * @deprecated Use /ws/:endpoint/:arg with cookie or x-token header instead.
195
+ */
103
196
  this.express.ws(`${this.mount}/ws-token/:token/:endpoint/:arg`, async (ws, req) => {
104
- const token = String(req.params.token);
105
- const endpointName = String(req.params.endpoint);
106
- const arg = String(req.params.arg ?? '');
107
- const result = await this.authProvider.getInfo(token);
108
- if (result.isErr()) {
109
- ws.close(WsCloseCodes.Unauthorized);
110
- return;
197
+ try {
198
+ if (!this.isOriginAllowed(req)) {
199
+ ws.close(WsCloseCodes.Denied);
200
+ return;
201
+ }
202
+ const token = String(req.params.token);
203
+ const endpointName = String(req.params.endpoint);
204
+ const arg = String(req.params.arg ?? '');
205
+ const result = await this.authProvider.getInfo(token);
206
+ if (result.isErr()) {
207
+ ws.close(WsCloseCodes.Unauthorized);
208
+ return;
209
+ }
210
+ const clientInfo = result.unwrap();
211
+ await this.handle(token, clientInfo, endpointName, arg, ws);
212
+ }
213
+ catch (err) {
214
+ logger.error('Unhandled rejection in /ws-token/:token/:endpoint/:arg:', err);
215
+ try {
216
+ ws.close(WsCloseCodes.InternalError);
217
+ }
218
+ catch { }
111
219
  }
112
- const clientInfo = result.unwrap();
113
- await this.handle(token, clientInfo, endpointName, arg, ws);
114
220
  });
221
+ /**
222
+ * @deprecated Use /ws/:endpoint with cookie or x-token header instead.
223
+ */
115
224
  this.express.ws(`${this.mount}/ws-token/:token/:endpoint`, async (ws, req) => {
116
- const token = String(req.params.token);
117
- const endpointName = String(req.params.endpoint);
118
- const arg = '';
119
- const result = await this.authProvider.getInfo(token);
120
- if (result.isErr()) {
121
- ws.close(WsCloseCodes.Unauthorized);
122
- return;
225
+ try {
226
+ if (!this.isOriginAllowed(req)) {
227
+ ws.close(WsCloseCodes.Denied);
228
+ return;
229
+ }
230
+ const token = String(req.params.token);
231
+ const endpointName = String(req.params.endpoint);
232
+ const arg = '';
233
+ const result = await this.authProvider.getInfo(token);
234
+ if (result.isErr()) {
235
+ ws.close(WsCloseCodes.Unauthorized);
236
+ return;
237
+ }
238
+ const info = result.unwrap();
239
+ await this.handle(token, info, endpointName, arg, ws);
240
+ }
241
+ catch (err) {
242
+ logger.error('Unhandled rejection in /ws-token/:token/:endpoint:', err);
243
+ try {
244
+ ws.close(WsCloseCodes.InternalError);
245
+ }
246
+ catch { }
123
247
  }
124
- const info = result.unwrap();
125
- await this.handle(token, info, endpointName, arg, ws);
126
248
  });
127
249
  this.express.ws(`${this.mount}/ws/:endpoint/:arg`, async (ws, req) => {
128
- if (!req.info || !req.token) {
129
- ws.close(WsCloseCodes.Unauthorized);
130
- return;
250
+ try {
251
+ if (!this.isOriginAllowed(req)) {
252
+ ws.close(WsCloseCodes.Denied);
253
+ return;
254
+ }
255
+ if (!req.info || !req.token) {
256
+ ws.close(WsCloseCodes.Unauthorized);
257
+ return;
258
+ }
259
+ const arg = String(req.params.arg ?? '');
260
+ const endpointName = String(req.params.endpoint);
261
+ await this.handle(req.token, req.info, endpointName, arg, ws);
262
+ }
263
+ catch (err) {
264
+ logger.error('Unhandled rejection in /ws/:endpoint/:arg:', err);
265
+ try {
266
+ ws.close(WsCloseCodes.InternalError);
267
+ }
268
+ catch { }
131
269
  }
132
- const arg = String(req.params.arg ?? '');
133
- const endpointName = String(req.params.endpoint);
134
- await this.handle(req.token, req.info, endpointName, arg, ws);
135
270
  });
136
271
  this.express.ws(`${this.mount}/ws/:endpoint`, async (ws, req) => {
137
- if (!req.info || !req.token) {
138
- ws.close(WsCloseCodes.Unauthorized);
139
- return;
272
+ try {
273
+ if (!this.isOriginAllowed(req)) {
274
+ ws.close(WsCloseCodes.Denied);
275
+ return;
276
+ }
277
+ if (!req.info || !req.token) {
278
+ ws.close(WsCloseCodes.Unauthorized);
279
+ return;
280
+ }
281
+ const arg = '';
282
+ const endpointName = String(req.params.endpoint);
283
+ await this.handle(req.token, req.info, endpointName, arg, ws);
284
+ }
285
+ catch (err) {
286
+ logger.error('Unhandled rejection in /ws/:endpoint:', err);
287
+ try {
288
+ ws.close(WsCloseCodes.InternalError);
289
+ }
290
+ catch { }
140
291
  }
141
- const arg = '';
142
- const endpointName = String(req.params.endpoint);
143
- await this.handle(req.token, req.info, endpointName, arg, ws);
144
292
  });
145
293
  }
146
294
  initAuth() {
@@ -150,7 +298,7 @@ export default class Endpoint {
150
298
  this.express.get(`${this.mount}/version`, (_req, res) => {
151
299
  res.status(200).json({ version: this.version }).end();
152
300
  });
153
- this.express.post(`${this.mount}/auth`, async (req, res) => {
301
+ this.express.post(`${this.mount}/auth`, this.authRateLimiter, async (req, res) => {
154
302
  const result = await this.authProvider.auth(req.body);
155
303
  if (result.isErr()) {
156
304
  logger.error('API Auth - Login Error: ', result.e);
@@ -165,28 +313,76 @@ export default class Endpoint {
165
313
  res.cookie('token', authResponse, this.COOKIE_OPTIONS).status(200).end();
166
314
  }
167
315
  });
168
- this.express.post(`${this.mount}/gauth`, async (req, res) => {
169
- const access_token = req.body.access_token;
170
- if (!access_token) {
316
+ // TODO (D6): Long-term improvement - have frontend send Google ID token and verify using google-auth-library
317
+ this.express.post(`${this.mount}/gauth`, this.authRateLimiter, async (req, res) => {
318
+ if (!this.googleClientIds || this.googleClientIds.length === 0) {
319
+ logger.error('Google Auth Error: "googleClientIds" is not configured on Endpoint.');
320
+ res.status(503).send({ message: 'Google authentication is not configured on this server.' }).end();
321
+ return;
322
+ }
323
+ const access_token = req.body?.access_token;
324
+ if (!access_token || typeof access_token !== 'string') {
171
325
  logger.error('Google Auth Validation Error: "access_token" is required in body.');
172
326
  res.status(422).send({ message: '"access_token" is required in body.' }).end();
173
327
  return;
174
328
  }
175
- const result = await wrap(axios.get(`https://www.googleapis.com/oauth2/v1/userinfo?access_token=${access_token}`, {
329
+ const tokenInfoResult = await wrap(axios.get(`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(access_token)}`, {
330
+ timeout: 10000,
331
+ }));
332
+ if (tokenInfoResult.isErr()) {
333
+ logger.error('Google Auth Token Info Error: ', tokenInfoResult.e);
334
+ res.status(401).send({ error: tokenInfoResult.e }).end();
335
+ return;
336
+ }
337
+ const tokenInfoResponse = tokenInfoResult.unwrap();
338
+ if (tokenInfoResponse.status !== 200 || !tokenInfoResponse.data) {
339
+ logger.error('Google Auth Token Info Status not 200: ', tokenInfoResponse.data);
340
+ res.status(401).send({ error: tokenInfoResponse.data }).end();
341
+ return;
342
+ }
343
+ const tokenData = tokenInfoResponse.data;
344
+ const isAudValid = typeof tokenData.aud === 'string' && this.googleClientIds.includes(tokenData.aud);
345
+ const isAzpValid = typeof tokenData.azp === 'string' && this.googleClientIds.includes(tokenData.azp);
346
+ const isClientAuthorized = isAudValid || isAzpValid;
347
+ const isEmailVerified = tokenData.email_verified === 'true' || tokenData.email_verified === true;
348
+ if (!isClientAuthorized || !isEmailVerified) {
349
+ logger.error('Google Auth Token Verification Failed: token not issued for configured client or email not verified.', {
350
+ aud: tokenData.aud,
351
+ azp: tokenData.azp,
352
+ email_verified: tokenData.email_verified,
353
+ });
354
+ res.status(401).send({ error: 'Unauthorized Google token: client ID mismatch or email not verified.' }).end();
355
+ return;
356
+ }
357
+ const userInfoResult = await wrap(axios.get('https://www.googleapis.com/oauth2/v1/userinfo', {
176
358
  headers: { Authorization: `Bearer ${access_token}`, Accept: 'application/json' },
359
+ timeout: 10000,
177
360
  }));
178
- if (result.isErr()) {
179
- logger.error('Google Auth API Error: ', result.e);
180
- res.status(401).send({ error: result.e }).end();
361
+ if (userInfoResult.isErr()) {
362
+ logger.error('Google Auth API Error: ', userInfoResult.e);
363
+ res.status(401).send({ error: userInfoResult.e }).end();
181
364
  return;
182
365
  }
183
- const authResponse = result.unwrap();
366
+ const authResponse = userInfoResult.unwrap();
184
367
  if (authResponse.status !== 200) {
185
368
  logger.error('Google Auth Status not 200: ', authResponse.data);
186
369
  res.status(401).send({ error: authResponse.data }).end();
187
370
  return;
188
371
  }
189
372
  const googleData = authResponse.data;
373
+ if (googleData?.verified_email === false) {
374
+ logger.error('Google Auth UserInfo Verification Error: email is not verified.', googleData);
375
+ res.status(401).send({ error: 'Google email is not verified.' }).end();
376
+ return;
377
+ }
378
+ if (tokenData.email && googleData?.email && tokenData.email.toLowerCase() !== googleData.email.toLowerCase()) {
379
+ logger.error('Google Auth Verification Error: tokeninfo email does not match userinfo email.', {
380
+ tokenEmail: tokenData.email,
381
+ userEmail: googleData.email,
382
+ });
383
+ res.status(401).send({ error: 'Google token email does not match userinfo.' }).end();
384
+ return;
385
+ }
190
386
  if (GoogleData.is(googleData)) {
191
387
  const authRes = await this.authProvider.googleAuth(googleData);
192
388
  if (authRes.isErr()) {
@@ -211,9 +407,9 @@ export default class Endpoint {
211
407
  await this.authProvider.destroy(req.token);
212
408
  this.deauth(req.info, req.token);
213
409
  }
214
- res.clearCookie('token').status(200).send({ success: true }).end();
410
+ res.clearCookie('token', this.COOKIE_OPTIONS).status(200).send({ success: true }).end();
215
411
  });
216
- this.express.put(`${this.mount}/auth/refresh`, async (req, res) => {
412
+ this.express.put(`${this.mount}/auth/refresh`, this.authRateLimiter, async (req, res) => {
217
413
  const result = await this.authProvider.refreshToken(req.body.old_token);
218
414
  if (result.isErr()) {
219
415
  logger.error('API Auth - Refresh Token Error: ', result.e);
@@ -224,7 +420,17 @@ export default class Endpoint {
224
420
  res.cookie('token', token, this.COOKIE_OPTIONS).status(200).end();
225
421
  });
226
422
  }
423
+ sanitizeFilename(originalName) {
424
+ const base = path
425
+ .basename(originalName)
426
+ .replace(/[/\\]/g, '')
427
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, '')
428
+ .trim();
429
+ const safe = base === '.' || base === '..' || base === '' ? 'upload' : base;
430
+ return safe.slice(0, 255);
431
+ }
227
432
  initMulter(uploadDir) {
433
+ const sanitize = (name) => this.sanitizeFilename(name);
228
434
  const storage = multer.diskStorage({
229
435
  destination: function (_req, file, cb) {
230
436
  const id = uuid();
@@ -234,11 +440,67 @@ export default class Endpoint {
234
440
  cb(null, dir);
235
441
  },
236
442
  filename: function (_req, file, cb) {
443
+ file.originalname = sanitize(file.originalname || '');
237
444
  cb(null, file.originalname);
238
445
  },
239
446
  });
240
- const upload = multer({ storage: storage });
241
- this.express.post(`${this.mount}/file`, validateToken, upload.single('file'), (req, res) => {
447
+ const fileFilter = (_req, file, cb) => {
448
+ if (this.uploadAllowlist) {
449
+ const { extensions, mimetypes } = this.uploadAllowlist;
450
+ const safeName = sanitize(file.originalname || '');
451
+ const ext = path.extname(safeName).toLowerCase().replace(/^\./, '');
452
+ const mime = (file.mimetype || '').toLowerCase();
453
+ if (extensions && extensions.length > 0) {
454
+ const normalizedExts = extensions.map((e) => e.toLowerCase().replace(/^\./, ''));
455
+ if (!normalizedExts.includes(ext)) {
456
+ const err = new Error('File extension not allowed');
457
+ err.statusCode = 415;
458
+ cb(err);
459
+ return;
460
+ }
461
+ }
462
+ if (mimetypes && mimetypes.length > 0) {
463
+ const normalizedMimes = mimetypes.map((m) => m.toLowerCase());
464
+ if (!normalizedMimes.includes(mime)) {
465
+ const err = new Error('MIME type not allowed');
466
+ err.statusCode = 415;
467
+ cb(err);
468
+ return;
469
+ }
470
+ }
471
+ }
472
+ cb(null, true);
473
+ };
474
+ const upload = multer({
475
+ storage: storage,
476
+ limits: {
477
+ fileSize: this.maxUploadBytes,
478
+ files: 1,
479
+ },
480
+ fileFilter,
481
+ });
482
+ this.express.post(`${this.mount}/file`, validateToken, (req, res, next) => {
483
+ upload.single('file')(req, res, (err) => {
484
+ if (err) {
485
+ if (err instanceof multer.MulterError) {
486
+ if (err.code === 'LIMIT_FILE_SIZE') {
487
+ res.status(413).json({ error: `File exceeds maximum allowed size of ${this.maxUploadBytes} bytes` });
488
+ return;
489
+ }
490
+ res.status(400).json({ error: err.message });
491
+ return;
492
+ }
493
+ const status = err.statusCode || 400;
494
+ res.status(status).json({ error: err.message || 'Upload error' });
495
+ return;
496
+ }
497
+ if (!req.file) {
498
+ res.status(400).json({ error: 'No file uploaded' });
499
+ return;
500
+ }
501
+ next();
502
+ });
503
+ }, (req, res) => {
242
504
  const { originalname, size, mimetype, id } = req.file;
243
505
  const fileInfo = {
244
506
  name: originalname,
@@ -248,7 +510,12 @@ export default class Endpoint {
248
510
  userId: req.info?.id ?? '',
249
511
  };
250
512
  const infoPath = path.join(uploadDir, fileInfo.id, 'index.json');
251
- fs.writeFile(infoPath, JSON.stringify(fileInfo), () => {
513
+ fs.writeFile(infoPath, JSON.stringify(fileInfo), (err) => {
514
+ if (err) {
515
+ logger.error('Failed to write file metadata:', err);
516
+ res.status(500).json({ error: 'Failed to save file metadata' }).end();
517
+ return;
518
+ }
252
519
  res.status(200).json(fileInfo).end();
253
520
  });
254
521
  });
@@ -260,12 +527,35 @@ export default class Endpoint {
260
527
  res.status(404).end();
261
528
  }
262
529
  else {
263
- const info = JSON.parse(data.toString());
264
- res.redirect(302, `${req.baseUrl}${req.path}/${info.name}`);
530
+ try {
531
+ const info = JSON.parse(data.toString());
532
+ if (!info || typeof info.name !== 'string') {
533
+ res.status(404).end();
534
+ return;
535
+ }
536
+ const safeName = encodeURIComponent(path.basename(info.name));
537
+ if (!safeName) {
538
+ res.status(404).end();
539
+ return;
540
+ }
541
+ res.redirect(302, `${req.baseUrl}${req.path}/${safeName}`);
542
+ }
543
+ catch (parseErr) {
544
+ logger.error('Corrupt index.json for fileId:', fileId, parseErr);
545
+ res.status(404).end();
546
+ }
265
547
  }
266
548
  });
267
549
  });
268
- this.express.use(`${this.mount}/file/`, validateToken, expressCore.static(this.uploadDir));
550
+ this.express.use(`${this.mount}/file/`, validateToken, expressCore.static(this.uploadDir, {
551
+ index: false,
552
+ dotfiles: 'deny',
553
+ setHeaders: (res) => {
554
+ res.setHeader('X-Content-Type-Options', 'nosniff');
555
+ res.setHeader('Content-Disposition', 'attachment');
556
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
557
+ },
558
+ }));
269
559
  }
270
560
  init2FA() {
271
561
  this.express.post(`${this.mount}/2fa/request`, validateToken, async (req, res) => {
@@ -282,6 +572,14 @@ export default class Endpoint {
282
572
  res.status(500).send({ error }).end();
283
573
  }
284
574
  });
575
+ /**
576
+ * Enables 2FA for the authenticated user.
577
+ *
578
+ * SECURITY REQUIREMENT:
579
+ * The AuthProvider implementation MUST verify that the `secret` in the request body
580
+ * matches the secret that the server issued in `request2FA` for this user, rather than
581
+ * blindly accepting an arbitrary secret provided by the client.
582
+ */
285
583
  this.express.post(`${this.mount}/2fa/enable`, validateToken, async (req, res) => {
286
584
  try {
287
585
  const { code, secret } = req.body;
@@ -305,7 +603,7 @@ export default class Endpoint {
305
603
  res.status(500).send({ error }).end();
306
604
  }
307
605
  });
308
- this.express.post(`${this.mount}/2fa/verify`, async (req, res) => {
606
+ this.express.post(`${this.mount}/2fa/verify`, this.authRateLimiter, async (req, res) => {
309
607
  try {
310
608
  const { accountName, code, uuid: uuidCode } = req.body;
311
609
  if (!accountName || !code || !uuidCode) {
@@ -332,7 +630,9 @@ export default class Endpoint {
332
630
  });
333
631
  this.express.delete(`${this.mount}/2fa/disable`, validateToken, async (req, res) => {
334
632
  try {
335
- const result = await this.authProvider.disable2FA(req.info?.id ?? '');
633
+ const rawCode = req.body?.code ?? req.query?.code;
634
+ const code = rawCode !== undefined && rawCode !== null ? String(rawCode) : undefined;
635
+ const result = await this.authProvider.disable2FA(req.info?.id ?? '', code);
336
636
  if (result.isErr()) {
337
637
  logger.error('API Auth - 2FA Disable Error: ', result.e);
338
638
  res.status(401).send({ error: result.e }).end();
@@ -348,32 +648,127 @@ export default class Endpoint {
348
648
  initMinio(minio) {
349
649
  const minioService = new MinioService(minio);
350
650
  this.express.post(`${this.mount}/attachments/upload`, validateToken, async (req, res) => {
351
- const { attachmentId, mimetype, fileName } = req.body;
352
- if (!attachmentId || !mimetype || !fileName) {
353
- res.status(400).send({ message: 'attachmentId, mimetype, and fileName are required.' }).end();
354
- return;
651
+ try {
652
+ if (!req.info?.id) {
653
+ res.status(401).send({ message: 'Unauthorized' }).end();
654
+ return;
655
+ }
656
+ const { mimetype, fileName } = req.body || {};
657
+ if (!mimetype || typeof mimetype !== 'string' || !fileName || typeof fileName !== 'string') {
658
+ res.status(400).send({ message: 'mimetype and fileName are required strings.' }).end();
659
+ return;
660
+ }
661
+ const decodedFileName = (() => {
662
+ try {
663
+ return decodeURIComponent(fileName);
664
+ }
665
+ catch {
666
+ return fileName;
667
+ }
668
+ })();
669
+ if (fileName.includes('..') ||
670
+ fileName.startsWith('/') ||
671
+ fileName.startsWith('\\') ||
672
+ decodedFileName.includes('..') ||
673
+ decodedFileName.startsWith('/') ||
674
+ decodedFileName.startsWith('\\')) {
675
+ res.status(400).send({ message: 'Invalid fileName: path traversal and leading slash are not allowed.' }).end();
676
+ return;
677
+ }
678
+ let ext = path.extname(fileName).replace(/^\./, '').trim().toLowerCase();
679
+ if (!ext && mimetype) {
680
+ const mimeExt = MimeToExtension[mimetype];
681
+ if (mimeExt && mimeExt !== '*') {
682
+ ext = mimeExt.toLowerCase();
683
+ }
684
+ }
685
+ if (ext && !/^[a-zA-Z0-9]+$/.test(ext)) {
686
+ ext = '';
687
+ }
688
+ const userId = req.info.id;
689
+ const fileId = uuid();
690
+ const key = ext ? `${userId}/${fileId}.${ext}` : `${userId}/${fileId}`;
691
+ const urlResult = await minioService.presignedPutObject(mimetype, key);
692
+ if (urlResult.isErr()) {
693
+ logger.error('Error on Upload Request API: ', urlResult.e);
694
+ res.status(500).send({ error: urlResult.e }).end();
695
+ return;
696
+ }
697
+ const url = urlResult.unwrap();
698
+ res.status(200).json({ url, key, attachmentId: key }).end();
355
699
  }
356
- const minioFileName = `${req.info?.id}/${fileName}`;
357
- const urlResult = await minioService.presignedPutObject(mimetype, minioFileName);
358
- if (urlResult.isErr()) {
359
- logger.error('Error on Upload Request API: ', urlResult.e);
360
- res.status(500).send(urlResult.e).end();
361
- return;
700
+ catch (error) {
701
+ logger.error('Error on Upload Request API: ', error);
702
+ res.status(500).send({ error }).end();
362
703
  }
363
- const url = urlResult.unwrap();
364
- res.status(200).json({ url }).end();
365
704
  });
366
- this.express.get(`${this.mount}/attachments/:attachmentId`, validateToken, async (req, res) => {
705
+ const handleGetAttachment = async (req, res) => {
367
706
  try {
368
- const attachmentId = String(req.params.attachmentId);
369
- const fileURL = await minioService.presignedGetObject(attachmentId);
707
+ if (!req.info?.id) {
708
+ res.status(401).send({ message: 'Unauthorized' }).end();
709
+ return;
710
+ }
711
+ let attachmentId = '';
712
+ if (req.params.userId && req.params.fileName) {
713
+ attachmentId = `${req.params.userId}/${req.params.fileName}`;
714
+ }
715
+ else if (req.params.attachmentId) {
716
+ attachmentId = String(req.params.attachmentId);
717
+ }
718
+ if (!attachmentId) {
719
+ res.status(400).send({ message: 'attachmentId is required.' }).end();
720
+ return;
721
+ }
722
+ const decoded = (() => {
723
+ try {
724
+ return decodeURIComponent(attachmentId);
725
+ }
726
+ catch {
727
+ return attachmentId;
728
+ }
729
+ })();
730
+ if (attachmentId.includes('..') ||
731
+ attachmentId.startsWith('/') ||
732
+ attachmentId.startsWith('\\') ||
733
+ decoded.includes('..') ||
734
+ decoded.startsWith('/') ||
735
+ decoded.startsWith('\\')) {
736
+ res.status(400).send({ message: 'Invalid attachmentId: path traversal and leading slash are not allowed.' }).end();
737
+ return;
738
+ }
739
+ const userId = req.info.id;
740
+ let objectKey;
741
+ if (attachmentId.startsWith(`${userId}/`)) {
742
+ objectKey = attachmentId;
743
+ }
744
+ else if (!attachmentId.includes('/')) {
745
+ objectKey = `${userId}/${attachmentId}`;
746
+ }
747
+ else {
748
+ logger.error(`IDOR attempt or unauthorized attachment access denied for user '${userId}': ${attachmentId}`);
749
+ res.status(403).send({ message: 'Forbidden: access to attachment denied.' }).end();
750
+ return;
751
+ }
752
+ if (objectKey === `${userId}/` || objectKey.endsWith('/')) {
753
+ res.status(400).send({ message: 'Invalid attachmentId: file name cannot be empty.' }).end();
754
+ return;
755
+ }
756
+ const fileURLResult = await minioService.presignedGetObject(objectKey);
757
+ if (fileURLResult.isErr()) {
758
+ logger.error('Error on Get File API: ', fileURLResult.e);
759
+ res.status(500).send({ error: fileURLResult.e }).end();
760
+ return;
761
+ }
762
+ const fileURL = fileURLResult.unwrap();
370
763
  res.status(200).json({ fileURL }).end();
371
764
  }
372
765
  catch (error) {
373
766
  logger.error('Error on Get File API: ', error);
374
- res.status(500).send(error).end();
767
+ res.status(500).send({ error }).end();
375
768
  }
376
- });
769
+ };
770
+ this.express.get(`${this.mount}/attachments/:attachmentId`, validateToken, handleGetAttachment);
771
+ this.express.get(`${this.mount}/attachments/:userId/:fileName`, validateToken, handleGetAttachment);
377
772
  }
378
773
  initMobileRequests() {
379
774
  this.express.post(`${this.mount}/mobile/:module/:action`, validateToken, async (req, res) => {