@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.
- package/README.md +73 -1
- package/dist/src/Platform.d.ts +12 -1
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +217 -77
- package/dist/src/Platform.js.map +1 -1
- package/dist/src/agent/AgentController.d.ts +24 -3
- package/dist/src/agent/AgentController.d.ts.map +1 -1
- package/dist/src/agent/AgentController.js +168 -60
- package/dist/src/agent/AgentController.js.map +1 -1
- package/dist/src/agent/worker.js +5 -0
- package/dist/src/agent/worker.js.map +1 -1
- package/dist/src/executor/Instance.d.ts +9 -3
- package/dist/src/executor/Instance.d.ts.map +1 -1
- package/dist/src/executor/Instance.js +148 -36
- package/dist/src/executor/Instance.js.map +1 -1
- package/dist/src/server/AuthProvider.d.ts +70 -1
- package/dist/src/server/AuthProvider.d.ts.map +1 -1
- package/dist/src/server/AuthProvider.js +12 -1
- package/dist/src/server/AuthProvider.js.map +1 -1
- package/dist/src/server/Endpoint.d.ts +30 -1
- package/dist/src/server/Endpoint.d.ts.map +1 -1
- package/dist/src/server/Endpoint.js +467 -72
- package/dist/src/server/Endpoint.js.map +1 -1
- package/dist/src/server/index.d.ts +1 -0
- package/dist/src/server/index.d.ts.map +1 -1
- package/dist/src/server/index.js +1 -0
- package/dist/src/server/index.js.map +1 -1
- package/dist/src/utils/log_middleware.d.ts.map +1 -1
- package/dist/src/utils/log_middleware.js +2 -1
- package/dist/src/utils/log_middleware.js.map +1 -1
- package/dist/src/utils/minioService.d.ts +1 -0
- package/dist/src/utils/minioService.d.ts.map +1 -1
- package/dist/src/utils/minioService.js +6 -3
- package/dist/src/utils/minioService.js.map +1 -1
- package/dist/tests/agentWorker.test.d.ts +2 -0
- package/dist/tests/agentWorker.test.d.ts.map +1 -0
- package/dist/tests/agentWorker.test.js +225 -0
- package/dist/tests/agentWorker.test.js.map +1 -0
- package/dist/tests/authProvider.test.js +15 -0
- package/dist/tests/authProvider.test.js.map +1 -1
- package/dist/tests/endpointSecurity.test.d.ts +2 -0
- package/dist/tests/endpointSecurity.test.d.ts.map +1 -0
- package/dist/tests/endpointSecurity.test.js +1007 -0
- package/dist/tests/endpointSecurity.test.js.map +1 -0
- package/dist/tests/logMiddleware.test.js +50 -0
- package/dist/tests/logMiddleware.test.js.map +1 -1
- package/dist/tests/minioService.test.js +178 -1
- package/dist/tests/minioService.test.js.map +1 -1
- package/dist/tests/platform.test.js +245 -0
- package/dist/tests/platform.test.js.map +1 -1
- package/dist/tests/unhandledRejections.test.d.ts +2 -0
- package/dist/tests/unhandledRejections.test.d.ts.map +1 -0
- package/dist/tests/unhandledRejections.test.js +204 -0
- package/dist/tests/unhandledRejections.test.js.map +1 -0
- package/dist/tests/uploadSecurity.test.d.ts +2 -0
- package/dist/tests/uploadSecurity.test.d.ts.map +1 -0
- package/dist/tests/uploadSecurity.test.js +219 -0
- package/dist/tests/uploadSecurity.test.js.map +1 -0
- 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
|
-
|
|
17
|
-
|
|
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.
|
|
44
|
-
this.
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
if (!
|
|
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
|
|
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 (
|
|
179
|
-
logger.error('Google Auth API Error: ',
|
|
180
|
-
res.status(401).send({ error:
|
|
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 =
|
|
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
|
|
241
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
705
|
+
const handleGetAttachment = async (req, res) => {
|
|
367
706
|
try {
|
|
368
|
-
|
|
369
|
-
|
|
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) => {
|