@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
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
import { deal, Ok } from '@araminix/lib';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import assert from 'node:assert';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
9
|
+
import request from 'supertest';
|
|
10
|
+
import { UserInfo } from '../src/server/AuthProvider.js';
|
|
11
|
+
import Endpoint from '../src/server/Endpoint.js';
|
|
12
|
+
describe('Endpoint Session Cookie and Header Security', () => {
|
|
13
|
+
let mockAuth;
|
|
14
|
+
let endpoint;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockAuth = {
|
|
17
|
+
getInfo: async (token) => {
|
|
18
|
+
if (token === 'valid-token') {
|
|
19
|
+
return new Ok(new UserInfo('user-1', 'john', 'John Doe', 'rt-1', 'john@example.com', [], { _key: 'u1', name: 'Unit 1', code: 'U1' }));
|
|
20
|
+
}
|
|
21
|
+
return { isErr: () => true, isOk: () => false, unwrap: () => null };
|
|
22
|
+
},
|
|
23
|
+
auth: async () => new Ok('new-session-token-123'),
|
|
24
|
+
googleAuth: async () => new Ok('google-session-token-456'),
|
|
25
|
+
destroy: async () => { },
|
|
26
|
+
refreshToken: async () => new Ok('refreshed-session-token-789'),
|
|
27
|
+
request2FA: async () => ({}),
|
|
28
|
+
verify2FA: async () => new Ok('2fa-session-token-abc'),
|
|
29
|
+
enable2FA: async () => ({}),
|
|
30
|
+
disable2FA: async () => ({}),
|
|
31
|
+
requestUploadUrl: async () => ({}),
|
|
32
|
+
requestDownloadUrl: async () => ({}),
|
|
33
|
+
mobileRequests: async () => ({}),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
if (endpoint) {
|
|
38
|
+
endpoint.close();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
it('sets session cookie with HttpOnly, Secure, SameSite=Lax, and Path=/ by default', async () => {
|
|
42
|
+
endpoint = new Endpoint({
|
|
43
|
+
authProvider: mockAuth,
|
|
44
|
+
port: 0,
|
|
45
|
+
});
|
|
46
|
+
const res = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
47
|
+
assert.strictEqual(res.status, 200);
|
|
48
|
+
const cookies = res.headers['set-cookie'];
|
|
49
|
+
assert.ok(Array.isArray(cookies) && cookies.length > 0, 'Set-Cookie header must be present');
|
|
50
|
+
const tokenCookie = cookies[0];
|
|
51
|
+
assert.ok(tokenCookie.startsWith('token='), 'Cookie name must be token');
|
|
52
|
+
assert.ok(tokenCookie.includes('HttpOnly'), 'Cookie must have HttpOnly flag');
|
|
53
|
+
assert.ok(tokenCookie.includes('Secure'), 'Cookie must have Secure flag');
|
|
54
|
+
assert.ok(/SameSite=Lax/i.test(tokenCookie), 'Cookie must have SameSite=Lax');
|
|
55
|
+
assert.ok(/Path=\//i.test(tokenCookie), 'Cookie must have Path=/');
|
|
56
|
+
});
|
|
57
|
+
it('allows cookieOptions to override default cookie flags', async () => {
|
|
58
|
+
endpoint = new Endpoint({
|
|
59
|
+
authProvider: mockAuth,
|
|
60
|
+
port: 0,
|
|
61
|
+
cookieOptions: {
|
|
62
|
+
secure: false,
|
|
63
|
+
sameSite: 'strict',
|
|
64
|
+
path: '/app',
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const res = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
68
|
+
assert.strictEqual(res.status, 200);
|
|
69
|
+
const cookies = res.headers['set-cookie'];
|
|
70
|
+
assert.ok(Array.isArray(cookies) && cookies.length > 0);
|
|
71
|
+
const tokenCookie = cookies[0];
|
|
72
|
+
assert.ok(tokenCookie.startsWith('token='));
|
|
73
|
+
assert.ok(tokenCookie.includes('HttpOnly'));
|
|
74
|
+
assert.ok(!tokenCookie.includes('Secure'), 'Secure flag must be omitted when overridden to false');
|
|
75
|
+
assert.ok(/SameSite=Strict/i.test(tokenCookie), 'SameSite must be Strict');
|
|
76
|
+
assert.ok(/Path=\/app/i.test(tokenCookie), 'Path must be /app');
|
|
77
|
+
});
|
|
78
|
+
it('clears session cookie on DELETE /auth using configured cookieOptions', async () => {
|
|
79
|
+
endpoint = new Endpoint({
|
|
80
|
+
authProvider: mockAuth,
|
|
81
|
+
port: 0,
|
|
82
|
+
cookieOptions: {
|
|
83
|
+
path: '/app',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const res = await request(endpoint.express).delete('/auth');
|
|
87
|
+
assert.strictEqual(res.status, 200);
|
|
88
|
+
const cookies = res.headers['set-cookie'];
|
|
89
|
+
assert.ok(Array.isArray(cookies) && cookies.length > 0);
|
|
90
|
+
const tokenCookie = cookies[0];
|
|
91
|
+
assert.ok(tokenCookie.startsWith('token='));
|
|
92
|
+
assert.ok(/Path=\/app/i.test(tokenCookie), 'Cleared cookie must preserve matching Path');
|
|
93
|
+
assert.ok(/Expires=Thu, 01 Jan 1970/i.test(tokenCookie), 'Cleared cookie must have past expiration');
|
|
94
|
+
});
|
|
95
|
+
it('authenticates WebSocket upgrade to /ws/:endpoint using x-token header', async () => {
|
|
96
|
+
endpoint = new Endpoint({
|
|
97
|
+
authProvider: mockAuth,
|
|
98
|
+
port: 0,
|
|
99
|
+
cookieOptions: { secure: false },
|
|
100
|
+
});
|
|
101
|
+
endpoint.declare('agent');
|
|
102
|
+
endpoint.open();
|
|
103
|
+
const address = endpoint.server?.address();
|
|
104
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
105
|
+
const [upgradePromise, resolveUpgrade] = deal();
|
|
106
|
+
const req = http.request({
|
|
107
|
+
port,
|
|
108
|
+
path: '/ws/agent',
|
|
109
|
+
agent: false,
|
|
110
|
+
headers: {
|
|
111
|
+
Connection: 'Upgrade',
|
|
112
|
+
Upgrade: 'websocket',
|
|
113
|
+
'Sec-WebSocket-Version': '13',
|
|
114
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
115
|
+
'x-token': 'valid-token',
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
req.on('upgrade', (res, socket) => {
|
|
119
|
+
resolveUpgrade({ statusCode: res.statusCode || 101, headers: res.headers });
|
|
120
|
+
socket.destroy();
|
|
121
|
+
});
|
|
122
|
+
req.end();
|
|
123
|
+
const upgradeResult = await upgradePromise;
|
|
124
|
+
assert.strictEqual(upgradeResult.statusCode, 101, 'Upgrade must succeed with HTTP 101 Switching Protocols');
|
|
125
|
+
});
|
|
126
|
+
it('rejects WebSocket upgrade to /ws/:endpoint without token or cookie', async () => {
|
|
127
|
+
endpoint = new Endpoint({
|
|
128
|
+
authProvider: mockAuth,
|
|
129
|
+
port: 0,
|
|
130
|
+
cookieOptions: { secure: false },
|
|
131
|
+
});
|
|
132
|
+
endpoint.declare('agent');
|
|
133
|
+
endpoint.open();
|
|
134
|
+
const address = endpoint.server?.address();
|
|
135
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
136
|
+
const [closePromise, resolveClose] = deal();
|
|
137
|
+
const req = http.request({
|
|
138
|
+
port,
|
|
139
|
+
path: '/ws/agent',
|
|
140
|
+
agent: false,
|
|
141
|
+
headers: {
|
|
142
|
+
Connection: 'Upgrade',
|
|
143
|
+
Upgrade: 'websocket',
|
|
144
|
+
'Sec-WebSocket-Version': '13',
|
|
145
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
req.on('upgrade', (_res, socket, head) => {
|
|
149
|
+
if (head && head.length > 0) {
|
|
150
|
+
resolveClose({ closed: true });
|
|
151
|
+
socket.destroy();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
socket.on('data', () => {
|
|
155
|
+
resolveClose({ closed: true });
|
|
156
|
+
socket.destroy();
|
|
157
|
+
});
|
|
158
|
+
socket.on('close', () => {
|
|
159
|
+
resolveClose({ closed: true });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
req.end();
|
|
163
|
+
const closeResult = await closePromise;
|
|
164
|
+
assert.strictEqual(closeResult.closed, true);
|
|
165
|
+
});
|
|
166
|
+
it('maintains backward compatibility for /ws-token/:token/:endpoint route', async () => {
|
|
167
|
+
endpoint = new Endpoint({
|
|
168
|
+
authProvider: mockAuth,
|
|
169
|
+
port: 0,
|
|
170
|
+
cookieOptions: { secure: false },
|
|
171
|
+
});
|
|
172
|
+
endpoint.declare('legacy');
|
|
173
|
+
endpoint.open();
|
|
174
|
+
const address = endpoint.server?.address();
|
|
175
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
176
|
+
const [upgradePromise, resolveUpgrade] = deal();
|
|
177
|
+
const req = http.request({
|
|
178
|
+
port,
|
|
179
|
+
path: '/ws-token/valid-token/legacy',
|
|
180
|
+
headers: {
|
|
181
|
+
Connection: 'Upgrade',
|
|
182
|
+
Upgrade: 'websocket',
|
|
183
|
+
'Sec-WebSocket-Version': '13',
|
|
184
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
req.on('upgrade', (res, socket) => {
|
|
188
|
+
resolveUpgrade({ statusCode: res.statusCode || 101 });
|
|
189
|
+
socket.destroy();
|
|
190
|
+
});
|
|
191
|
+
req.end();
|
|
192
|
+
const upgradeResult = await upgradePromise;
|
|
193
|
+
assert.strictEqual(upgradeResult.statusCode, 101);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
describe('Endpoint Google OAuth Security (/gauth)', () => {
|
|
197
|
+
let mockAuth;
|
|
198
|
+
let endpoint;
|
|
199
|
+
let originalAxiosGet;
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
originalAxiosGet = axios.get;
|
|
202
|
+
mockAuth = {
|
|
203
|
+
getInfo: async () => ({ isErr: () => true, isOk: () => false, unwrap: () => null }),
|
|
204
|
+
auth: async () => new Ok('session-token'),
|
|
205
|
+
googleAuth: async (data) => new Ok(`google-token-for-${data.email}`),
|
|
206
|
+
destroy: async () => { },
|
|
207
|
+
refreshToken: async () => new Ok('refreshed-token'),
|
|
208
|
+
request2FA: async () => ({}),
|
|
209
|
+
verify2FA: async () => new Ok('2fa-token'),
|
|
210
|
+
enable2FA: async () => ({}),
|
|
211
|
+
disable2FA: async () => ({}),
|
|
212
|
+
requestUploadUrl: async () => ({}),
|
|
213
|
+
requestDownloadUrl: async () => ({}),
|
|
214
|
+
mobileRequests: async () => ({}),
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
afterEach(() => {
|
|
218
|
+
axios.get = originalAxiosGet;
|
|
219
|
+
if (endpoint) {
|
|
220
|
+
endpoint.close();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
it('refuses /gauth with 503 when googleClientIds is not configured', async () => {
|
|
224
|
+
endpoint = new Endpoint({
|
|
225
|
+
authProvider: mockAuth,
|
|
226
|
+
port: 0,
|
|
227
|
+
});
|
|
228
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: 'some-token' });
|
|
229
|
+
assert.strictEqual(res.status, 503);
|
|
230
|
+
});
|
|
231
|
+
it('refuses /gauth with 503 when googleClientIds is empty array', async () => {
|
|
232
|
+
endpoint = new Endpoint({
|
|
233
|
+
authProvider: mockAuth,
|
|
234
|
+
port: 0,
|
|
235
|
+
googleClientIds: [],
|
|
236
|
+
});
|
|
237
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: 'some-token' });
|
|
238
|
+
assert.strictEqual(res.status, 503);
|
|
239
|
+
});
|
|
240
|
+
it('rejects /gauth with 422 when access_token is missing or not a string', async () => {
|
|
241
|
+
endpoint = new Endpoint({
|
|
242
|
+
authProvider: mockAuth,
|
|
243
|
+
port: 0,
|
|
244
|
+
googleClientIds: ['my-client-id.apps.googleusercontent.com'],
|
|
245
|
+
});
|
|
246
|
+
const res1 = await request(endpoint.express).post('/gauth').send({});
|
|
247
|
+
assert.strictEqual(res1.status, 422);
|
|
248
|
+
const res2 = await request(endpoint.express).post('/gauth').send({ access_token: 12345 });
|
|
249
|
+
assert.strictEqual(res2.status, 422);
|
|
250
|
+
});
|
|
251
|
+
it('validates token with tokeninfo using encodeURIComponent and timeout', async () => {
|
|
252
|
+
const calls = [];
|
|
253
|
+
axios.get = (async (url, config) => {
|
|
254
|
+
calls.push({ url, config });
|
|
255
|
+
if (url.startsWith('https://oauth2.googleapis.com/tokeninfo')) {
|
|
256
|
+
return {
|
|
257
|
+
status: 200,
|
|
258
|
+
data: {
|
|
259
|
+
aud: 'my-client-id.apps.googleusercontent.com',
|
|
260
|
+
email_verified: 'true',
|
|
261
|
+
email: 'user@example.com',
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (url.startsWith('https://www.googleapis.com/oauth2/v1/userinfo')) {
|
|
266
|
+
return {
|
|
267
|
+
status: 200,
|
|
268
|
+
data: {
|
|
269
|
+
email: 'user@example.com',
|
|
270
|
+
name: 'User Name',
|
|
271
|
+
given_name: 'User',
|
|
272
|
+
family_name: 'Name',
|
|
273
|
+
picture: 'https://pic.example.com/photo.jpg',
|
|
274
|
+
verified_email: true,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`Unexpected url: ${url}`);
|
|
279
|
+
});
|
|
280
|
+
endpoint = new Endpoint({
|
|
281
|
+
authProvider: mockAuth,
|
|
282
|
+
port: 0,
|
|
283
|
+
googleClientIds: ['my-client-id.apps.googleusercontent.com'],
|
|
284
|
+
});
|
|
285
|
+
const rawToken = 'token+with special/chars&symbols=1';
|
|
286
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: rawToken });
|
|
287
|
+
assert.strictEqual(res.status, 200);
|
|
288
|
+
assert.strictEqual(calls.length, 2);
|
|
289
|
+
// Verify tokeninfo call
|
|
290
|
+
const tokenInfoCall = calls[0];
|
|
291
|
+
assert.strictEqual(tokenInfoCall.url, `https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(rawToken)}`);
|
|
292
|
+
assert.strictEqual(tokenInfoCall.config?.timeout, 10000);
|
|
293
|
+
// Verify userinfo call has Authorization header only and no query string
|
|
294
|
+
const userInfoCall = calls[1];
|
|
295
|
+
assert.strictEqual(userInfoCall.url, 'https://www.googleapis.com/oauth2/v1/userinfo');
|
|
296
|
+
assert.strictEqual(userInfoCall.config?.headers?.Authorization, `Bearer ${rawToken}`);
|
|
297
|
+
assert.strictEqual(userInfoCall.config?.timeout, 10000);
|
|
298
|
+
});
|
|
299
|
+
it('rejects /gauth with 401 when token was issued to a different client (token replay attack)', async () => {
|
|
300
|
+
const calls = [];
|
|
301
|
+
axios.get = (async (url) => {
|
|
302
|
+
calls.push({ url });
|
|
303
|
+
return {
|
|
304
|
+
status: 200,
|
|
305
|
+
data: {
|
|
306
|
+
aud: 'attacker-client-id.apps.googleusercontent.com',
|
|
307
|
+
azp: 'attacker-client-id.apps.googleusercontent.com',
|
|
308
|
+
email_verified: 'true',
|
|
309
|
+
email: 'victim@example.com',
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
endpoint = new Endpoint({
|
|
314
|
+
authProvider: mockAuth,
|
|
315
|
+
port: 0,
|
|
316
|
+
googleClientIds: ['my-client-id.apps.googleusercontent.com'],
|
|
317
|
+
});
|
|
318
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: 'attacker-replayed-token' });
|
|
319
|
+
assert.strictEqual(res.status, 401);
|
|
320
|
+
// Ensure userinfo was never called
|
|
321
|
+
assert.strictEqual(calls.length, 1);
|
|
322
|
+
});
|
|
323
|
+
it('rejects /gauth with 401 when email_verified is not "true"', async () => {
|
|
324
|
+
axios.get = (async (url) => {
|
|
325
|
+
return {
|
|
326
|
+
status: 200,
|
|
327
|
+
data: {
|
|
328
|
+
aud: 'my-client-id.apps.googleusercontent.com',
|
|
329
|
+
email_verified: 'false',
|
|
330
|
+
email: 'unverified@example.com',
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
endpoint = new Endpoint({
|
|
335
|
+
authProvider: mockAuth,
|
|
336
|
+
port: 0,
|
|
337
|
+
googleClientIds: ['my-client-id.apps.googleusercontent.com'],
|
|
338
|
+
});
|
|
339
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: 'unverified-token' });
|
|
340
|
+
assert.strictEqual(res.status, 401);
|
|
341
|
+
});
|
|
342
|
+
it('rejects /gauth with 401 when tokeninfo request fails', async () => {
|
|
343
|
+
axios.get = (async () => {
|
|
344
|
+
throw new Error('Google tokeninfo network error');
|
|
345
|
+
});
|
|
346
|
+
endpoint = new Endpoint({
|
|
347
|
+
authProvider: mockAuth,
|
|
348
|
+
port: 0,
|
|
349
|
+
googleClientIds: ['my-client-id.apps.googleusercontent.com'],
|
|
350
|
+
});
|
|
351
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: 'bad-token' });
|
|
352
|
+
assert.strictEqual(res.status, 401);
|
|
353
|
+
});
|
|
354
|
+
it('accepts token when azp matches googleClientIds', async () => {
|
|
355
|
+
axios.get = (async (url) => {
|
|
356
|
+
if (url.startsWith('https://oauth2.googleapis.com/tokeninfo')) {
|
|
357
|
+
return {
|
|
358
|
+
status: 200,
|
|
359
|
+
data: {
|
|
360
|
+
aud: 'other-aud',
|
|
361
|
+
azp: 'trusted-client-azp.apps.googleusercontent.com',
|
|
362
|
+
email_verified: 'true',
|
|
363
|
+
email: 'user@example.com',
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
status: 200,
|
|
369
|
+
data: {
|
|
370
|
+
email: 'user@example.com',
|
|
371
|
+
name: 'User Name',
|
|
372
|
+
given_name: 'User',
|
|
373
|
+
family_name: 'Name',
|
|
374
|
+
picture: 'https://pic.example.com/photo.jpg',
|
|
375
|
+
verified_email: true,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
endpoint = new Endpoint({
|
|
380
|
+
authProvider: mockAuth,
|
|
381
|
+
port: 0,
|
|
382
|
+
googleClientIds: ['trusted-client-azp.apps.googleusercontent.com'],
|
|
383
|
+
});
|
|
384
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: 'valid-token' });
|
|
385
|
+
assert.strictEqual(res.status, 200);
|
|
386
|
+
const cookies = res.headers['set-cookie'];
|
|
387
|
+
assert.ok(Array.isArray(cookies) && cookies.length > 0);
|
|
388
|
+
});
|
|
389
|
+
it('rejects /gauth with 401 when userinfo email does not match tokeninfo email', async () => {
|
|
390
|
+
axios.get = (async (url) => {
|
|
391
|
+
if (url.startsWith('https://oauth2.googleapis.com/tokeninfo')) {
|
|
392
|
+
return {
|
|
393
|
+
status: 200,
|
|
394
|
+
data: {
|
|
395
|
+
aud: 'my-client-id.apps.googleusercontent.com',
|
|
396
|
+
email_verified: 'true',
|
|
397
|
+
email: 'user1@example.com',
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
status: 200,
|
|
403
|
+
data: {
|
|
404
|
+
email: 'user2@example.com',
|
|
405
|
+
name: 'User Name',
|
|
406
|
+
given_name: 'User',
|
|
407
|
+
family_name: 'Name',
|
|
408
|
+
picture: 'https://pic.example.com/photo.jpg',
|
|
409
|
+
verified_email: true,
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
});
|
|
413
|
+
endpoint = new Endpoint({
|
|
414
|
+
authProvider: mockAuth,
|
|
415
|
+
port: 0,
|
|
416
|
+
googleClientIds: ['my-client-id.apps.googleusercontent.com'],
|
|
417
|
+
});
|
|
418
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: 'mismatched-token' });
|
|
419
|
+
assert.strictEqual(res.status, 401);
|
|
420
|
+
});
|
|
421
|
+
it('rejects /gauth with 401 when userinfo verified_email is false', async () => {
|
|
422
|
+
axios.get = (async (url) => {
|
|
423
|
+
if (url.startsWith('https://oauth2.googleapis.com/tokeninfo')) {
|
|
424
|
+
return {
|
|
425
|
+
status: 200,
|
|
426
|
+
data: {
|
|
427
|
+
aud: 'my-client-id.apps.googleusercontent.com',
|
|
428
|
+
email_verified: 'true',
|
|
429
|
+
email: 'user@example.com',
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
status: 200,
|
|
435
|
+
data: {
|
|
436
|
+
email: 'user@example.com',
|
|
437
|
+
name: 'User Name',
|
|
438
|
+
given_name: 'User',
|
|
439
|
+
family_name: 'Name',
|
|
440
|
+
picture: 'https://pic.example.com/photo.jpg',
|
|
441
|
+
verified_email: false,
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
endpoint = new Endpoint({
|
|
446
|
+
authProvider: mockAuth,
|
|
447
|
+
port: 0,
|
|
448
|
+
googleClientIds: ['my-client-id.apps.googleusercontent.com'],
|
|
449
|
+
});
|
|
450
|
+
const res = await request(endpoint.express).post('/gauth').send({ access_token: 'token-with-unverified-userinfo' });
|
|
451
|
+
assert.strictEqual(res.status, 401);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
describe('Endpoint Cross-Site WebSocket Hijacking Protection', () => {
|
|
455
|
+
let mockAuth;
|
|
456
|
+
let endpoint;
|
|
457
|
+
beforeEach(() => {
|
|
458
|
+
mockAuth = {
|
|
459
|
+
getInfo: async (token) => {
|
|
460
|
+
if (token === 'valid-token') {
|
|
461
|
+
return new Ok(new UserInfo('user-1', 'john', 'John Doe', 'rt-1', 'john@example.com', [], { _key: 'u1', name: 'Unit 1', code: 'U1' }));
|
|
462
|
+
}
|
|
463
|
+
return { isErr: () => true, isOk: () => false, unwrap: () => null };
|
|
464
|
+
},
|
|
465
|
+
auth: async () => new Ok('valid-token'),
|
|
466
|
+
googleAuth: async () => new Ok('valid-token'),
|
|
467
|
+
destroy: async () => { },
|
|
468
|
+
refreshToken: async () => new Ok('valid-token'),
|
|
469
|
+
request2FA: async () => ({}),
|
|
470
|
+
verify2FA: async () => new Ok('valid-token'),
|
|
471
|
+
enable2FA: async () => ({}),
|
|
472
|
+
disable2FA: async () => ({}),
|
|
473
|
+
requestUploadUrl: async () => ({}),
|
|
474
|
+
requestDownloadUrl: async () => ({}),
|
|
475
|
+
mobileRequests: async () => ({ status: 200 }),
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
afterEach(() => {
|
|
479
|
+
if (endpoint) {
|
|
480
|
+
endpoint.close();
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
it('allows WebSocket upgrade when Origin matches allowedOrigins', async () => {
|
|
484
|
+
endpoint = new Endpoint({
|
|
485
|
+
authProvider: mockAuth,
|
|
486
|
+
port: 0,
|
|
487
|
+
cookieOptions: { secure: false },
|
|
488
|
+
allowedOrigins: ['https://app.example.com'],
|
|
489
|
+
});
|
|
490
|
+
endpoint.declare('agent');
|
|
491
|
+
endpoint.open();
|
|
492
|
+
const address = endpoint.server?.address();
|
|
493
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
494
|
+
const [upgradePromise, resolveUpgrade] = deal();
|
|
495
|
+
const req = http.request({
|
|
496
|
+
port,
|
|
497
|
+
path: '/ws/agent',
|
|
498
|
+
agent: false,
|
|
499
|
+
headers: {
|
|
500
|
+
Connection: 'Upgrade',
|
|
501
|
+
Upgrade: 'websocket',
|
|
502
|
+
'Sec-WebSocket-Version': '13',
|
|
503
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
504
|
+
Origin: 'https://app.example.com',
|
|
505
|
+
'x-token': 'valid-token',
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
req.on('upgrade', (res, socket) => {
|
|
509
|
+
resolveUpgrade({ statusCode: res.statusCode || 101 });
|
|
510
|
+
socket.destroy();
|
|
511
|
+
});
|
|
512
|
+
req.end();
|
|
513
|
+
const upgradeResult = await upgradePromise;
|
|
514
|
+
assert.strictEqual(upgradeResult.statusCode, 101);
|
|
515
|
+
});
|
|
516
|
+
it('rejects WebSocket upgrade when Origin is not in allowedOrigins', async () => {
|
|
517
|
+
endpoint = new Endpoint({
|
|
518
|
+
authProvider: mockAuth,
|
|
519
|
+
port: 0,
|
|
520
|
+
cookieOptions: { secure: false },
|
|
521
|
+
allowedOrigins: ['https://app.example.com'],
|
|
522
|
+
});
|
|
523
|
+
endpoint.declare('agent');
|
|
524
|
+
endpoint.open();
|
|
525
|
+
const address = endpoint.server?.address();
|
|
526
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
527
|
+
const [rejectPromise, resolveReject] = deal();
|
|
528
|
+
const req = http.request({
|
|
529
|
+
port,
|
|
530
|
+
path: '/ws/agent',
|
|
531
|
+
agent: false,
|
|
532
|
+
headers: {
|
|
533
|
+
Connection: 'Upgrade',
|
|
534
|
+
Upgrade: 'websocket',
|
|
535
|
+
'Sec-WebSocket-Version': '13',
|
|
536
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
537
|
+
Origin: 'https://evil.com',
|
|
538
|
+
'x-token': 'valid-token',
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
req.on('response', (res) => {
|
|
542
|
+
resolveReject({ statusCode: res.statusCode || 403 });
|
|
543
|
+
});
|
|
544
|
+
req.on('upgrade', (_res, socket) => {
|
|
545
|
+
socket.destroy();
|
|
546
|
+
resolveReject({ statusCode: 101 });
|
|
547
|
+
});
|
|
548
|
+
req.end();
|
|
549
|
+
const rejectResult = await rejectPromise;
|
|
550
|
+
assert.strictEqual(rejectResult.statusCode, 403);
|
|
551
|
+
});
|
|
552
|
+
it('allows WebSocket upgrade when Origin is missing (non-browser client)', async () => {
|
|
553
|
+
endpoint = new Endpoint({
|
|
554
|
+
authProvider: mockAuth,
|
|
555
|
+
port: 0,
|
|
556
|
+
cookieOptions: { secure: false },
|
|
557
|
+
allowedOrigins: ['https://app.example.com'],
|
|
558
|
+
});
|
|
559
|
+
endpoint.declare('agent');
|
|
560
|
+
endpoint.open();
|
|
561
|
+
const address = endpoint.server?.address();
|
|
562
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
563
|
+
const [upgradePromise, resolveUpgrade] = deal();
|
|
564
|
+
const req = http.request({
|
|
565
|
+
port,
|
|
566
|
+
path: '/ws/agent',
|
|
567
|
+
agent: false,
|
|
568
|
+
headers: {
|
|
569
|
+
Connection: 'Upgrade',
|
|
570
|
+
Upgrade: 'websocket',
|
|
571
|
+
'Sec-WebSocket-Version': '13',
|
|
572
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
573
|
+
'x-token': 'valid-token',
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
req.on('upgrade', (res, socket) => {
|
|
577
|
+
resolveUpgrade({ statusCode: res.statusCode || 101 });
|
|
578
|
+
socket.destroy();
|
|
579
|
+
});
|
|
580
|
+
req.end();
|
|
581
|
+
const upgradeResult = await upgradePromise;
|
|
582
|
+
assert.strictEqual(upgradeResult.statusCode, 101);
|
|
583
|
+
});
|
|
584
|
+
it('rejects WebSocket upgrade when allowedOrigins is not configured and Origin is present', async () => {
|
|
585
|
+
endpoint = new Endpoint({
|
|
586
|
+
authProvider: mockAuth,
|
|
587
|
+
port: 0,
|
|
588
|
+
cookieOptions: { secure: false },
|
|
589
|
+
});
|
|
590
|
+
endpoint.declare('agent');
|
|
591
|
+
endpoint.open();
|
|
592
|
+
const address = endpoint.server?.address();
|
|
593
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
594
|
+
const [rejectPromise, resolveReject] = deal();
|
|
595
|
+
const req = http.request({
|
|
596
|
+
port,
|
|
597
|
+
path: '/ws/agent',
|
|
598
|
+
agent: false,
|
|
599
|
+
headers: {
|
|
600
|
+
Connection: 'Upgrade',
|
|
601
|
+
Upgrade: 'websocket',
|
|
602
|
+
'Sec-WebSocket-Version': '13',
|
|
603
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
604
|
+
Origin: 'https://evil.com',
|
|
605
|
+
'x-token': 'valid-token',
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
req.on('response', (res) => {
|
|
609
|
+
resolveReject({ statusCode: res.statusCode || 403 });
|
|
610
|
+
});
|
|
611
|
+
req.on('upgrade', (_res, socket) => {
|
|
612
|
+
socket.destroy();
|
|
613
|
+
resolveReject({ statusCode: 101 });
|
|
614
|
+
});
|
|
615
|
+
req.end();
|
|
616
|
+
const rejectResult = await rejectPromise;
|
|
617
|
+
assert.strictEqual(rejectResult.statusCode, 403);
|
|
618
|
+
});
|
|
619
|
+
it('normalizes origin case and trailing slash when validating allowedOrigins', async () => {
|
|
620
|
+
endpoint = new Endpoint({
|
|
621
|
+
authProvider: mockAuth,
|
|
622
|
+
port: 0,
|
|
623
|
+
cookieOptions: { secure: false },
|
|
624
|
+
allowedOrigins: ['https://app.example.com/'],
|
|
625
|
+
});
|
|
626
|
+
endpoint.declare('agent');
|
|
627
|
+
endpoint.open();
|
|
628
|
+
const address = endpoint.server?.address();
|
|
629
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
630
|
+
const [upgradePromise, resolveUpgrade] = deal();
|
|
631
|
+
const req = http.request({
|
|
632
|
+
port,
|
|
633
|
+
path: '/ws/agent',
|
|
634
|
+
agent: false,
|
|
635
|
+
headers: {
|
|
636
|
+
Connection: 'Upgrade',
|
|
637
|
+
Upgrade: 'websocket',
|
|
638
|
+
'Sec-WebSocket-Version': '13',
|
|
639
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
640
|
+
Origin: 'HTTPS://APP.EXAMPLE.COM',
|
|
641
|
+
'x-token': 'valid-token',
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
req.on('upgrade', (res, socket) => {
|
|
645
|
+
resolveUpgrade({ statusCode: res.statusCode || 101 });
|
|
646
|
+
socket.destroy();
|
|
647
|
+
});
|
|
648
|
+
req.end();
|
|
649
|
+
const upgradeResult = await upgradePromise;
|
|
650
|
+
assert.strictEqual(upgradeResult.statusCode, 101);
|
|
651
|
+
});
|
|
652
|
+
it('authenticates WebSocket upgrade via session cookie with allowed Origin', async () => {
|
|
653
|
+
endpoint = new Endpoint({
|
|
654
|
+
authProvider: mockAuth,
|
|
655
|
+
port: 0,
|
|
656
|
+
cookieOptions: { secure: false },
|
|
657
|
+
allowedOrigins: ['https://app.example.com'],
|
|
658
|
+
});
|
|
659
|
+
endpoint.declare('agent');
|
|
660
|
+
endpoint.open();
|
|
661
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
662
|
+
const cookie = authRes.headers['set-cookie']?.[0]?.split(';')[0];
|
|
663
|
+
assert.ok(cookie, 'Cookie must be set');
|
|
664
|
+
const address = endpoint.server?.address();
|
|
665
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
666
|
+
const [upgradePromise, resolveUpgrade] = deal();
|
|
667
|
+
const req = http.request({
|
|
668
|
+
port,
|
|
669
|
+
path: '/ws/agent',
|
|
670
|
+
agent: false,
|
|
671
|
+
headers: {
|
|
672
|
+
Connection: 'Upgrade',
|
|
673
|
+
Upgrade: 'websocket',
|
|
674
|
+
'Sec-WebSocket-Version': '13',
|
|
675
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
676
|
+
Origin: 'https://app.example.com',
|
|
677
|
+
Cookie: cookie,
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
req.on('upgrade', (res, socket) => {
|
|
681
|
+
resolveUpgrade({ statusCode: res.statusCode || 101 });
|
|
682
|
+
socket.destroy();
|
|
683
|
+
});
|
|
684
|
+
req.end();
|
|
685
|
+
const upgradeResult = await upgradePromise;
|
|
686
|
+
assert.strictEqual(upgradeResult.statusCode, 101);
|
|
687
|
+
});
|
|
688
|
+
it('rejects WebSocket upgrade via session cookie with unauthorized Origin (cross-site WebSocket hijacking attempt)', async () => {
|
|
689
|
+
endpoint = new Endpoint({
|
|
690
|
+
authProvider: mockAuth,
|
|
691
|
+
port: 0,
|
|
692
|
+
cookieOptions: { secure: false },
|
|
693
|
+
allowedOrigins: ['https://app.example.com'],
|
|
694
|
+
});
|
|
695
|
+
endpoint.declare('agent');
|
|
696
|
+
endpoint.open();
|
|
697
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
698
|
+
const cookie = authRes.headers['set-cookie']?.[0]?.split(';')[0];
|
|
699
|
+
assert.ok(cookie);
|
|
700
|
+
const address = endpoint.server?.address();
|
|
701
|
+
const port = typeof address === 'object' && address ? address.port : 3000;
|
|
702
|
+
const [rejectPromise, resolveReject] = deal();
|
|
703
|
+
const req = http.request({
|
|
704
|
+
port,
|
|
705
|
+
path: '/ws/agent',
|
|
706
|
+
agent: false,
|
|
707
|
+
headers: {
|
|
708
|
+
Connection: 'Upgrade',
|
|
709
|
+
Upgrade: 'websocket',
|
|
710
|
+
'Sec-WebSocket-Version': '13',
|
|
711
|
+
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
712
|
+
Origin: 'https://attacker-hijack.com',
|
|
713
|
+
Cookie: cookie,
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
req.on('response', (res) => {
|
|
717
|
+
resolveReject({ statusCode: res.statusCode || 403 });
|
|
718
|
+
});
|
|
719
|
+
req.on('upgrade', (_res, socket) => {
|
|
720
|
+
socket.destroy();
|
|
721
|
+
resolveReject({ statusCode: 101 });
|
|
722
|
+
});
|
|
723
|
+
req.end();
|
|
724
|
+
const rejectResult = await rejectPromise;
|
|
725
|
+
assert.strictEqual(rejectResult.statusCode, 403);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
describe('Endpoint CSRF Defense for Cookie-Authenticated Mutations', () => {
|
|
729
|
+
let mockAuth;
|
|
730
|
+
let endpoint;
|
|
731
|
+
let tempDir;
|
|
732
|
+
beforeEach(() => {
|
|
733
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'araminix-csrf-test-'));
|
|
734
|
+
mockAuth = {
|
|
735
|
+
getInfo: async (token) => {
|
|
736
|
+
if (token === 'valid-token') {
|
|
737
|
+
return new Ok(new UserInfo('user-1', 'john', 'John Doe', 'rt-1', 'john@example.com', [], { _key: 'u1', name: 'Unit 1', code: 'U1' }));
|
|
738
|
+
}
|
|
739
|
+
return { isErr: () => true, isOk: () => false, unwrap: () => null };
|
|
740
|
+
},
|
|
741
|
+
auth: async () => new Ok('valid-token'),
|
|
742
|
+
googleAuth: async () => new Ok('valid-token'),
|
|
743
|
+
destroy: async () => { },
|
|
744
|
+
refreshToken: async () => new Ok('valid-token'),
|
|
745
|
+
request2FA: async () => ({}),
|
|
746
|
+
verify2FA: async () => new Ok('valid-token'),
|
|
747
|
+
enable2FA: async () => ({}),
|
|
748
|
+
disable2FA: async () => ({}),
|
|
749
|
+
requestUploadUrl: async () => ({}),
|
|
750
|
+
requestDownloadUrl: async () => ({}),
|
|
751
|
+
mobileRequests: async (req) => ({ status: 200, data: req.data }),
|
|
752
|
+
};
|
|
753
|
+
});
|
|
754
|
+
afterEach(() => {
|
|
755
|
+
if (endpoint) {
|
|
756
|
+
endpoint.close();
|
|
757
|
+
}
|
|
758
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
759
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
it('allows cookie-authenticated POST /mobile/:module/:action with Content-Type: application/json', async () => {
|
|
763
|
+
endpoint = new Endpoint({
|
|
764
|
+
authProvider: mockAuth,
|
|
765
|
+
port: 0,
|
|
766
|
+
cookieOptions: { secure: false },
|
|
767
|
+
});
|
|
768
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
769
|
+
const cookie = authRes.headers['set-cookie'];
|
|
770
|
+
const res = await request(endpoint.express).post('/mobile/system/restart').set('Cookie', cookie).set('Content-Type', 'application/json').send({ reason: 'maintenance' });
|
|
771
|
+
assert.strictEqual(res.status, 200);
|
|
772
|
+
});
|
|
773
|
+
it('allows cookie-authenticated POST /mobile/:module/:action with custom X-Requested-With header', async () => {
|
|
774
|
+
endpoint = new Endpoint({
|
|
775
|
+
authProvider: mockAuth,
|
|
776
|
+
port: 0,
|
|
777
|
+
cookieOptions: { secure: false },
|
|
778
|
+
});
|
|
779
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
780
|
+
const cookie = authRes.headers['set-cookie'];
|
|
781
|
+
const res = await request(endpoint.express)
|
|
782
|
+
.post('/mobile/system/restart')
|
|
783
|
+
.set('Cookie', cookie)
|
|
784
|
+
.set('X-Requested-With', 'XMLHttpRequest')
|
|
785
|
+
.set('Content-Type', 'application/x-www-form-urlencoded')
|
|
786
|
+
.send('reason=maintenance');
|
|
787
|
+
assert.strictEqual(res.status, 200);
|
|
788
|
+
});
|
|
789
|
+
it('rejects cookie-authenticated POST /mobile/:module/:action simple form request with 403 Forbidden', async () => {
|
|
790
|
+
endpoint = new Endpoint({
|
|
791
|
+
authProvider: mockAuth,
|
|
792
|
+
port: 0,
|
|
793
|
+
cookieOptions: { secure: false },
|
|
794
|
+
});
|
|
795
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
796
|
+
const cookie = authRes.headers['set-cookie'];
|
|
797
|
+
const res = await request(endpoint.express)
|
|
798
|
+
.post('/mobile/system/restart')
|
|
799
|
+
.set('Cookie', cookie)
|
|
800
|
+
.set('Content-Type', 'application/x-www-form-urlencoded')
|
|
801
|
+
.send('reason=malicious_cross_site_action');
|
|
802
|
+
assert.strictEqual(res.status, 403);
|
|
803
|
+
assert.ok(res.body.error || res.body.message);
|
|
804
|
+
});
|
|
805
|
+
it('rejects cookie-authenticated POST /mobile/:module/:action simple text/plain request with 403 Forbidden', async () => {
|
|
806
|
+
endpoint = new Endpoint({
|
|
807
|
+
authProvider: mockAuth,
|
|
808
|
+
port: 0,
|
|
809
|
+
cookieOptions: { secure: false },
|
|
810
|
+
});
|
|
811
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
812
|
+
const cookie = authRes.headers['set-cookie'];
|
|
813
|
+
const res = await request(endpoint.express).post('/mobile/system/restart').set('Cookie', cookie).set('Content-Type', 'text/plain').send('{"reason": "exploit"}');
|
|
814
|
+
assert.strictEqual(res.status, 403);
|
|
815
|
+
});
|
|
816
|
+
it('rejects cookie-authenticated POST /file simple request without custom headers with 403 Forbidden', async () => {
|
|
817
|
+
endpoint = new Endpoint({
|
|
818
|
+
authProvider: mockAuth,
|
|
819
|
+
uploadDir: tempDir,
|
|
820
|
+
port: 0,
|
|
821
|
+
cookieOptions: { secure: false },
|
|
822
|
+
});
|
|
823
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
824
|
+
const cookie = authRes.headers['set-cookie'];
|
|
825
|
+
const res = await request(endpoint.express).post('/file').set('Cookie', cookie).attach('file', Buffer.from('malicious payload'), 'exploit.txt');
|
|
826
|
+
assert.strictEqual(res.status, 403);
|
|
827
|
+
});
|
|
828
|
+
it('allows cookie-authenticated POST /file with X-Requested-With header', async () => {
|
|
829
|
+
endpoint = new Endpoint({
|
|
830
|
+
authProvider: mockAuth,
|
|
831
|
+
uploadDir: tempDir,
|
|
832
|
+
port: 0,
|
|
833
|
+
cookieOptions: { secure: false },
|
|
834
|
+
});
|
|
835
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
836
|
+
const cookie = authRes.headers['set-cookie'];
|
|
837
|
+
const res = await request(endpoint.express)
|
|
838
|
+
.post('/file')
|
|
839
|
+
.set('Cookie', cookie)
|
|
840
|
+
.set('X-Requested-With', 'XMLHttpRequest')
|
|
841
|
+
.attach('file', Buffer.from('valid file content'), 'upload.txt');
|
|
842
|
+
assert.strictEqual(res.status, 200);
|
|
843
|
+
assert.strictEqual(res.body.name, 'upload.txt');
|
|
844
|
+
});
|
|
845
|
+
it('allows header-authenticated (x-token) mutations without X-Requested-With or application/json', async () => {
|
|
846
|
+
endpoint = new Endpoint({
|
|
847
|
+
authProvider: mockAuth,
|
|
848
|
+
uploadDir: tempDir,
|
|
849
|
+
port: 0,
|
|
850
|
+
});
|
|
851
|
+
const resMobile = await request(endpoint.express)
|
|
852
|
+
.post('/mobile/system/restart')
|
|
853
|
+
.set('x-token', 'valid-token')
|
|
854
|
+
.set('Content-Type', 'application/x-www-form-urlencoded')
|
|
855
|
+
.send('reason=api_call');
|
|
856
|
+
assert.strictEqual(resMobile.status, 200);
|
|
857
|
+
const resUpload = await request(endpoint.express).post('/file').set('x-token', 'valid-token').attach('file', Buffer.from('api upload'), 'api.txt');
|
|
858
|
+
assert.strictEqual(resUpload.status, 200);
|
|
859
|
+
});
|
|
860
|
+
it('allows cookie-authenticated non-mutation GET requests without custom headers', async () => {
|
|
861
|
+
endpoint = new Endpoint({
|
|
862
|
+
authProvider: mockAuth,
|
|
863
|
+
uploadDir: tempDir,
|
|
864
|
+
port: 0,
|
|
865
|
+
cookieOptions: { secure: false },
|
|
866
|
+
});
|
|
867
|
+
const authRes = await request(endpoint.express).post('/auth').send({ username: 'john' });
|
|
868
|
+
const cookie = authRes.headers['set-cookie'];
|
|
869
|
+
const res = await request(endpoint.express).get('/file/non-existent-id').set('Cookie', cookie);
|
|
870
|
+
// Not blocked by CSRF 403, returns standard 404 for missing file
|
|
871
|
+
assert.strictEqual(res.status, 404);
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
describe('Endpoint Login and 2FA Hardening', () => {
|
|
875
|
+
let mockAuth;
|
|
876
|
+
let endpoint;
|
|
877
|
+
let capturedDisable2FAArgs;
|
|
878
|
+
let disable2FAResult = new Ok(true);
|
|
879
|
+
beforeEach(() => {
|
|
880
|
+
capturedDisable2FAArgs = undefined;
|
|
881
|
+
disable2FAResult = new Ok(true);
|
|
882
|
+
mockAuth = {
|
|
883
|
+
getInfo: async (token) => {
|
|
884
|
+
if (token === 'valid-token') {
|
|
885
|
+
return new Ok(new UserInfo('user-1', 'john', 'John Doe', 'rt-1', 'john@example.com', [], {
|
|
886
|
+
_key: 'u1',
|
|
887
|
+
name: 'Unit 1',
|
|
888
|
+
code: 'U1',
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
return { isErr: () => true, isOk: () => false, unwrap: () => null };
|
|
892
|
+
},
|
|
893
|
+
auth: async (data) => {
|
|
894
|
+
if (data?.username === 'error-user') {
|
|
895
|
+
return { isErr: () => true, isOk: () => false, e: 'invalid credentials' };
|
|
896
|
+
}
|
|
897
|
+
return new Ok('session-token-123');
|
|
898
|
+
},
|
|
899
|
+
googleAuth: async () => new Ok('google-session-token'),
|
|
900
|
+
destroy: async () => { },
|
|
901
|
+
refreshToken: async () => new Ok('new-token-456'),
|
|
902
|
+
request2FA: async () => new Ok('server-generated-secret-xyz'),
|
|
903
|
+
verify2FA: async () => new Ok('verified-2fa-token'),
|
|
904
|
+
enable2FA: async () => new Ok(true),
|
|
905
|
+
disable2FA: async (userId, code) => {
|
|
906
|
+
capturedDisable2FAArgs = { userId, code };
|
|
907
|
+
return disable2FAResult;
|
|
908
|
+
},
|
|
909
|
+
requestUploadUrl: async () => ({}),
|
|
910
|
+
requestDownloadUrl: async () => ({}),
|
|
911
|
+
mobileRequests: async () => ({}),
|
|
912
|
+
};
|
|
913
|
+
});
|
|
914
|
+
afterEach(() => {
|
|
915
|
+
if (endpoint) {
|
|
916
|
+
endpoint.close();
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
it('enforces rate limiting on /auth keyed by IP and account', async () => {
|
|
920
|
+
endpoint = new Endpoint({
|
|
921
|
+
authProvider: mockAuth,
|
|
922
|
+
port: 0,
|
|
923
|
+
authRateLimit: { limit: 2, windowMs: 60000 },
|
|
924
|
+
});
|
|
925
|
+
const res1 = await request(endpoint.express).post('/auth').send({ username: 'userA' });
|
|
926
|
+
assert.strictEqual(res1.status, 200);
|
|
927
|
+
const res2 = await request(endpoint.express).post('/auth').send({ username: 'userA' });
|
|
928
|
+
assert.strictEqual(res2.status, 200);
|
|
929
|
+
const res3 = await request(endpoint.express).post('/auth').send({ username: 'userA' });
|
|
930
|
+
assert.strictEqual(res3.status, 429);
|
|
931
|
+
assert.strictEqual(res3.body.error, 'Too many requests');
|
|
932
|
+
// Different account on same IP has its own bucket
|
|
933
|
+
const resUserB = await request(endpoint.express).post('/auth').send({ username: 'userB' });
|
|
934
|
+
assert.strictEqual(resUserB.status, 200);
|
|
935
|
+
});
|
|
936
|
+
it('enforces rate limiting on /2fa/verify keyed by IP and account', async () => {
|
|
937
|
+
endpoint = new Endpoint({
|
|
938
|
+
authProvider: mockAuth,
|
|
939
|
+
port: 0,
|
|
940
|
+
authRateLimit: { limit: 2, windowMs: 60000 },
|
|
941
|
+
});
|
|
942
|
+
const payloadA = { accountName: 'userA', code: '123456', uuid: 'uuid-1' };
|
|
943
|
+
const res1 = await request(endpoint.express).post('/2fa/verify').send(payloadA);
|
|
944
|
+
assert.strictEqual(res1.status, 200);
|
|
945
|
+
const res2 = await request(endpoint.express).post('/2fa/verify').send(payloadA);
|
|
946
|
+
assert.strictEqual(res2.status, 200);
|
|
947
|
+
const res3 = await request(endpoint.express).post('/2fa/verify').send(payloadA);
|
|
948
|
+
assert.strictEqual(res3.status, 429);
|
|
949
|
+
// Different account on same IP is not throttled
|
|
950
|
+
const payloadB = { accountName: 'userB', code: '123456', uuid: 'uuid-1' };
|
|
951
|
+
const resUserB = await request(endpoint.express).post('/2fa/verify').send(payloadB);
|
|
952
|
+
assert.strictEqual(resUserB.status, 200);
|
|
953
|
+
});
|
|
954
|
+
it('enforces rate limiting on /auth/refresh keyed by IP and account', async () => {
|
|
955
|
+
endpoint = new Endpoint({
|
|
956
|
+
authProvider: mockAuth,
|
|
957
|
+
port: 0,
|
|
958
|
+
authRateLimit: { limit: 2, windowMs: 60000 },
|
|
959
|
+
});
|
|
960
|
+
const res1 = await request(endpoint.express).put('/auth/refresh').send({ old_token: 'tokenA' });
|
|
961
|
+
assert.strictEqual(res1.status, 200);
|
|
962
|
+
const res2 = await request(endpoint.express).put('/auth/refresh').send({ old_token: 'tokenA' });
|
|
963
|
+
assert.strictEqual(res2.status, 200);
|
|
964
|
+
const res3 = await request(endpoint.express).put('/auth/refresh').send({ old_token: 'tokenA' });
|
|
965
|
+
assert.strictEqual(res3.status, 429);
|
|
966
|
+
const resTokenB = await request(endpoint.express).put('/auth/refresh').send({ old_token: 'tokenB' });
|
|
967
|
+
assert.strictEqual(resTokenB.status, 200);
|
|
968
|
+
});
|
|
969
|
+
it('allows rate limiting to be disabled via authRateLimit: false', async () => {
|
|
970
|
+
endpoint = new Endpoint({
|
|
971
|
+
authProvider: mockAuth,
|
|
972
|
+
port: 0,
|
|
973
|
+
authRateLimit: false,
|
|
974
|
+
});
|
|
975
|
+
for (let i = 0; i < 5; i++) {
|
|
976
|
+
const res = await request(endpoint.express).post('/auth').send({ username: 'userA' });
|
|
977
|
+
assert.strictEqual(res.status, 200);
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
it('accepts optional code parameter on DELETE /2fa/disable from body or query', async () => {
|
|
981
|
+
endpoint = new Endpoint({
|
|
982
|
+
authProvider: mockAuth,
|
|
983
|
+
port: 0,
|
|
984
|
+
});
|
|
985
|
+
// 1. Without code
|
|
986
|
+
const resWithoutCode = await request(endpoint.express).delete('/2fa/disable').set('x-token', 'valid-token');
|
|
987
|
+
assert.strictEqual(resWithoutCode.status, 200);
|
|
988
|
+
assert.strictEqual(capturedDisable2FAArgs?.userId, 'user-1');
|
|
989
|
+
assert.strictEqual(capturedDisable2FAArgs?.code, undefined);
|
|
990
|
+
// 2. With code in JSON body
|
|
991
|
+
const resWithBodyCode = await request(endpoint.express).delete('/2fa/disable').set('x-token', 'valid-token').send({ code: '654321' });
|
|
992
|
+
assert.strictEqual(resWithBodyCode.status, 200);
|
|
993
|
+
assert.strictEqual(capturedDisable2FAArgs?.userId, 'user-1');
|
|
994
|
+
assert.strictEqual(capturedDisable2FAArgs?.code, '654321');
|
|
995
|
+
// 3. With code in query string
|
|
996
|
+
const resWithQueryCode = await request(endpoint.express).delete('/2fa/disable?code=987654').set('x-token', 'valid-token');
|
|
997
|
+
assert.strictEqual(resWithQueryCode.status, 200);
|
|
998
|
+
assert.strictEqual(capturedDisable2FAArgs?.userId, 'user-1');
|
|
999
|
+
assert.strictEqual(capturedDisable2FAArgs?.code, '987654');
|
|
1000
|
+
// 4. When authProvider rejects disable2FA, returns 401
|
|
1001
|
+
disable2FAResult = { isErr: () => true, isOk: () => false, e: 'Invalid 2FA code' };
|
|
1002
|
+
const resFailed = await request(endpoint.express).delete('/2fa/disable').set('x-token', 'valid-token').send({ code: '000000' });
|
|
1003
|
+
assert.strictEqual(resFailed.status, 401);
|
|
1004
|
+
assert.strictEqual(resFailed.body.error, 'Invalid 2FA code');
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
//# sourceMappingURL=endpointSecurity.test.js.map
|