@eeacms/volto-eea-chatbot 2.0.4 → 2.0.5
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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/halloumi/middleware.js +16 -0
- package/src/halloumi/middleware.test.js +129 -9
- package/src/middleware.js +34 -0
- package/src/middleware.test.js +113 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
### [2.0.5](https://github.com/eea/volto-eea-chatbot/compare/2.0.4...2.0.5) - 21 June 2026
|
|
8
|
+
|
|
9
|
+
#### :bug: Bug Fixes
|
|
10
|
+
|
|
11
|
+
- fix: add method to halloumi middleware test req [Tiberiu Ichim - [`082c285`](https://github.com/eea/volto-eea-chatbot/commit/082c285636b6dad397af9778b49361d6e29b8dec)]
|
|
12
|
+
|
|
13
|
+
#### :hammer_and_wrench: Others
|
|
14
|
+
|
|
15
|
+
- Formatting [Tiberiu Ichim - [`aabcc7a`](https://github.com/eea/volto-eea-chatbot/commit/aabcc7aafe720d47e4cae72215db8dfd8b55f7d9)]
|
|
16
|
+
- Increase code coverage [Tiberiu Ichim - [`fdbcd85`](https://github.com/eea/volto-eea-chatbot/commit/fdbcd8580d44d1b3d41f024ae97670131951f822)]
|
|
7
17
|
### [2.0.4](https://github.com/eea/volto-eea-chatbot/compare/2.0.3...2.0.4) - 28 May 2026
|
|
8
18
|
|
|
9
19
|
#### :bug: Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
// import debug from 'debug';
|
|
2
2
|
import { getVerifyClaimResponse } from './generative';
|
|
3
|
+
import { isPathAllowed } from '../middleware';
|
|
3
4
|
|
|
4
5
|
// const log = debug('halloumi');
|
|
5
6
|
|
|
6
7
|
const MSG_INVALID_CONFIGURATION =
|
|
7
8
|
'Invalid configuration: missing LLMGW_TOKEN or LLMGW_URL';
|
|
8
9
|
|
|
10
|
+
// Allowed paths for _ha (Halloumi) proxy.
|
|
11
|
+
// When adding new endpoints, update this list.
|
|
12
|
+
const ALLOWED_HALLOUMI_PATHS = [
|
|
13
|
+
{ path: '/generate', methods: ['POST'] },
|
|
14
|
+
{ path: '/classify', methods: ['POST'] },
|
|
15
|
+
];
|
|
16
|
+
|
|
9
17
|
const LLMGW_URL = process.env.LLMGW_URL;
|
|
10
18
|
const LLMGW_TOKEN = process.env.LLMGW_TOKEN;
|
|
11
19
|
|
|
@@ -32,6 +40,14 @@ const classifyModel = {
|
|
|
32
40
|
export default async function middleware(req, res, next) {
|
|
33
41
|
const path = req.url.replace('/_ha/', '/');
|
|
34
42
|
|
|
43
|
+
// Reject paths not on the allowlist — prevents Confused Deputy attacks
|
|
44
|
+
if (!isPathAllowed(path, req.method, ALLOWED_HALLOUMI_PATHS)) {
|
|
45
|
+
res.statusCode = 404;
|
|
46
|
+
res.statusMessage = 'Not Found';
|
|
47
|
+
res.send({ error: 'Not Found' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
if (!(LLMGW_TOKEN && LLMGW_URL)) {
|
|
36
52
|
res.send({
|
|
37
53
|
error: MSG_INVALID_CONFIGURATION,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import middleware from './middleware';
|
|
2
|
+
import { isPathAllowed } from '../middleware';
|
|
2
3
|
|
|
3
4
|
jest.mock('./generative');
|
|
4
5
|
|
|
@@ -8,11 +9,14 @@ describe('halloumi middleware', () => {
|
|
|
8
9
|
beforeEach(() => {
|
|
9
10
|
req = {
|
|
10
11
|
url: '/_ha/generate',
|
|
12
|
+
method: 'POST',
|
|
11
13
|
body: {
|
|
12
14
|
sources: ['source1', 'source2'],
|
|
13
15
|
answer: 'test answer',
|
|
14
16
|
maxContextSegments: 3,
|
|
15
17
|
},
|
|
18
|
+
headers: {},
|
|
19
|
+
ip: '127.0.0.1',
|
|
16
20
|
};
|
|
17
21
|
res = {
|
|
18
22
|
send: jest.fn(),
|
|
@@ -42,28 +46,144 @@ describe('halloumi middleware', () => {
|
|
|
42
46
|
process.env.LLMGW_URL = origUrl;
|
|
43
47
|
});
|
|
44
48
|
|
|
45
|
-
it('
|
|
49
|
+
it('rejects disallowed paths with 404', async () => {
|
|
50
|
+
req.url = '/_ha/admin/config';
|
|
51
|
+
req.method = 'POST';
|
|
52
|
+
|
|
53
|
+
await middleware(req, res, next);
|
|
54
|
+
|
|
55
|
+
expect(res.statusCode).toBe(404);
|
|
56
|
+
expect(res.send).toHaveBeenCalledWith({ error: 'Not Found' });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects allowed path with wrong HTTP method', async () => {
|
|
60
|
+
req.url = '/_ha/generate';
|
|
61
|
+
req.method = 'GET';
|
|
62
|
+
|
|
63
|
+
await middleware(req, res, next);
|
|
64
|
+
|
|
65
|
+
expect(res.statusCode).toBe(404);
|
|
66
|
+
expect(res.send).toHaveBeenCalledWith({ error: 'Not Found' });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// These tests need a fresh module loaded with env vars set so that
|
|
71
|
+
// LLMGW_TOKEN and LLMGW_URL are defined at module load time,
|
|
72
|
+
// allowing the code to reach getVerifyClaimResponse.
|
|
73
|
+
describe('halloumi middleware - dynamic import', () => {
|
|
74
|
+
const buildReq = () => ({
|
|
75
|
+
url: '/_ha/generate',
|
|
76
|
+
method: 'POST',
|
|
77
|
+
body: {
|
|
78
|
+
sources: ['source1', 'source2'],
|
|
79
|
+
answer: 'test answer',
|
|
80
|
+
maxContextSegments: 3,
|
|
81
|
+
},
|
|
82
|
+
headers: {},
|
|
83
|
+
ip: '127.0.0.1',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const buildRes = () => ({
|
|
87
|
+
send: jest.fn(),
|
|
88
|
+
set: jest.fn(),
|
|
89
|
+
status: jest.fn().mockReturnThis(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('sends response on successful getVerifyClaimResponse', async () => {
|
|
46
93
|
const origToken = process.env.LLMGW_TOKEN;
|
|
47
94
|
const origUrl = process.env.LLMGW_URL;
|
|
48
95
|
process.env.LLMGW_TOKEN = 'test-token';
|
|
49
96
|
process.env.LLMGW_URL = 'http://test-url';
|
|
50
97
|
|
|
51
|
-
// Need to re-import since env vars are read at module level
|
|
52
98
|
jest.resetModules();
|
|
53
|
-
const genMod = require('./generative');
|
|
54
|
-
const middlewareMod = require('./middleware').default;
|
|
55
99
|
|
|
56
|
-
|
|
100
|
+
const mockGetVerifyClaimResponse = jest
|
|
57
101
|
.fn()
|
|
58
102
|
.mockResolvedValue({ claims: [], segments: {} });
|
|
59
103
|
|
|
60
|
-
|
|
104
|
+
jest.setMock('./generative', {
|
|
105
|
+
getVerifyClaimResponse: mockGetVerifyClaimResponse,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const middlewareMod = require('./middleware').default;
|
|
109
|
+
|
|
110
|
+
const req = buildReq();
|
|
111
|
+
const res = buildRes();
|
|
61
112
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
expect(
|
|
113
|
+
await middlewareMod(req, res, jest.fn());
|
|
114
|
+
|
|
115
|
+
expect(mockGetVerifyClaimResponse).toHaveBeenCalled();
|
|
116
|
+
expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/json');
|
|
117
|
+
expect(res.send).toHaveBeenCalledWith({ claims: [], segments: {} });
|
|
65
118
|
|
|
66
119
|
process.env.LLMGW_TOKEN = origToken;
|
|
67
120
|
process.env.LLMGW_URL = origUrl;
|
|
68
121
|
});
|
|
122
|
+
|
|
123
|
+
it('handles errors from getVerifyClaimResponse', async () => {
|
|
124
|
+
const origToken = process.env.LLMGW_TOKEN;
|
|
125
|
+
const origUrl = process.env.LLMGW_URL;
|
|
126
|
+
process.env.LLMGW_TOKEN = 'test-token';
|
|
127
|
+
process.env.LLMGW_URL = 'http://test-url';
|
|
128
|
+
|
|
129
|
+
jest.resetModules();
|
|
130
|
+
|
|
131
|
+
const mockGetVerifyClaimResponse = jest
|
|
132
|
+
.fn()
|
|
133
|
+
.mockRejectedValue(new Error('LLM error'));
|
|
134
|
+
|
|
135
|
+
jest.setMock('./generative', {
|
|
136
|
+
getVerifyClaimResponse: mockGetVerifyClaimResponse,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const middlewareMod = require('./middleware').default;
|
|
140
|
+
|
|
141
|
+
const req = buildReq();
|
|
142
|
+
const res = buildRes();
|
|
143
|
+
|
|
144
|
+
await middlewareMod(req, res, jest.fn());
|
|
145
|
+
|
|
146
|
+
expect(res.status).toHaveBeenCalledWith(500);
|
|
147
|
+
expect(res.send).toHaveBeenCalledWith(
|
|
148
|
+
expect.objectContaining({ error: expect.stringContaining('LLM error') }),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
process.env.LLMGW_TOKEN = origToken;
|
|
152
|
+
process.env.LLMGW_URL = origUrl;
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('halloumi path allowlist', () => {
|
|
157
|
+
// Mirror of ALLOWED_HALLOUMI_PATHS from middleware.js
|
|
158
|
+
const ALLOWED_HALLOUMI_PATHS = [
|
|
159
|
+
{ path: '/generate', methods: ['POST'] },
|
|
160
|
+
{ path: '/classify', methods: ['POST'] },
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
it('allows /generate with POST', () => {
|
|
164
|
+
expect(isPathAllowed('/generate', 'POST', ALLOWED_HALLOUMI_PATHS)).toBe(
|
|
165
|
+
true,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('allows /classify with POST', () => {
|
|
170
|
+
expect(isPathAllowed('/classify', 'POST', ALLOWED_HALLOUMI_PATHS)).toBe(
|
|
171
|
+
true,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('rejects /generate with wrong method', () => {
|
|
176
|
+
expect(isPathAllowed('/generate', 'GET', ALLOWED_HALLOUMI_PATHS)).toBe(
|
|
177
|
+
false,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('rejects disallowed paths', () => {
|
|
182
|
+
expect(isPathAllowed('/admin/config', 'POST', ALLOWED_HALLOUMI_PATHS)).toBe(
|
|
183
|
+
false,
|
|
184
|
+
);
|
|
185
|
+
expect(
|
|
186
|
+
isPathAllowed('/../../etc/passwd', 'POST', ALLOWED_HALLOUMI_PATHS),
|
|
187
|
+
).toBe(false);
|
|
188
|
+
});
|
|
69
189
|
});
|
package/src/middleware.js
CHANGED
|
@@ -9,6 +9,31 @@ const log = debug('volto-eea-chatbot');
|
|
|
9
9
|
|
|
10
10
|
const MOCK_STREAM_DELAY = parseInt(process.env.MOCK_STREAM_DELAY || '0');
|
|
11
11
|
|
|
12
|
+
// Allowed paths for _da and _rq proxy (shared).
|
|
13
|
+
// When adding new endpoints, update this list.
|
|
14
|
+
const ALLOWED_PROXY_PATHS = [
|
|
15
|
+
{ path: '/persona', methods: ['GET'] },
|
|
16
|
+
{ pathPattern: /^\/persona\/\d+$/, methods: ['GET'] },
|
|
17
|
+
{ path: '/chat/create-chat-session', methods: ['POST'] },
|
|
18
|
+
{ path: '/chat/send-message', methods: ['POST'] },
|
|
19
|
+
{ path: '/chat/send-chat-message', methods: ['POST'] },
|
|
20
|
+
{ path: '/chat/create-chat-message-feedback', methods: ['POST'] },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check whether a stripped path matches the allowlist.
|
|
25
|
+
* Strips query strings before comparison.
|
|
26
|
+
*/
|
|
27
|
+
function isPathAllowed(strippedPath, method, allowedPaths) {
|
|
28
|
+
const cleanPath = strippedPath.split('?')[0];
|
|
29
|
+
return allowedPaths.some(
|
|
30
|
+
(entry) =>
|
|
31
|
+
(entry.path === cleanPath ||
|
|
32
|
+
(entry.pathPattern && entry.pathPattern.test(cleanPath))) &&
|
|
33
|
+
entry.methods.includes(method),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
let cached_auth_cookie = null;
|
|
13
38
|
let last_fetched = null;
|
|
14
39
|
let maxAge;
|
|
@@ -249,10 +274,19 @@ async function send_onyx_request(
|
|
|
249
274
|
}
|
|
250
275
|
}
|
|
251
276
|
|
|
277
|
+
export { isPathAllowed, ALLOWED_PROXY_PATHS };
|
|
252
278
|
export default async function middleware(req, res, next) {
|
|
253
279
|
const is_related_question = req.url.includes('/_rq/');
|
|
254
280
|
const path = req.url.replace('/_da/', '/').replace('/_rq/', '/');
|
|
255
281
|
|
|
282
|
+
// Reject paths not on the allowlist — prevents Confused Deputy attacks
|
|
283
|
+
if (!isPathAllowed(path, req.method, ALLOWED_PROXY_PATHS)) {
|
|
284
|
+
res.statusCode = 404;
|
|
285
|
+
res.statusMessage = 'Not Found';
|
|
286
|
+
res.send({ error: 'Not Found' });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
256
290
|
const reqUrl = `${process.env.ONYX_URL || ''}/api${path}`;
|
|
257
291
|
|
|
258
292
|
const api_key = process.env.ONYX_API_KEY;
|
package/src/middleware.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Mock superagent
|
|
2
|
-
import middleware from './middleware';
|
|
2
|
+
import middleware, { isPathAllowed, ALLOWED_PROXY_PATHS } from './middleware';
|
|
3
3
|
|
|
4
4
|
jest.mock('superagent', () => ({
|
|
5
5
|
post: jest.fn().mockReturnValue({
|
|
@@ -234,4 +234,116 @@ describe('src/middleware', () => {
|
|
|
234
234
|
'/tmp/dumped_response.jsonl',
|
|
235
235
|
);
|
|
236
236
|
});
|
|
237
|
+
|
|
238
|
+
it('rejects disallowed paths with 404', async () => {
|
|
239
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
240
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
241
|
+
req.url = '/_da/admin/users/delete';
|
|
242
|
+
req.method = 'POST';
|
|
243
|
+
|
|
244
|
+
await middleware(req, res, next);
|
|
245
|
+
|
|
246
|
+
expect(res.statusCode).toBe(404);
|
|
247
|
+
expect(res.send).toHaveBeenCalledWith({ error: 'Not Found' });
|
|
248
|
+
expect(nodeFetch).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('rejects allowed path with wrong HTTP method', async () => {
|
|
252
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
253
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
254
|
+
req.url = '/_da/persona';
|
|
255
|
+
req.method = 'DELETE';
|
|
256
|
+
|
|
257
|
+
await middleware(req, res, next);
|
|
258
|
+
|
|
259
|
+
expect(res.statusCode).toBe(404);
|
|
260
|
+
expect(res.send).toHaveBeenCalledWith({ error: 'Not Found' });
|
|
261
|
+
expect(nodeFetch).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('rejects path traversal attempts', async () => {
|
|
265
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
266
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
267
|
+
req.url = '/_da/../../etc/passwd';
|
|
268
|
+
req.method = 'GET';
|
|
269
|
+
|
|
270
|
+
await middleware(req, res, next);
|
|
271
|
+
|
|
272
|
+
expect(res.statusCode).toBe(404);
|
|
273
|
+
expect(res.send).toHaveBeenCalledWith({ error: 'Not Found' });
|
|
274
|
+
expect(nodeFetch).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('isPathAllowed', () => {
|
|
279
|
+
it('allows all defined _da proxy paths with correct methods', () => {
|
|
280
|
+
expect(isPathAllowed('/persona', 'GET', ALLOWED_PROXY_PATHS)).toBe(true);
|
|
281
|
+
expect(isPathAllowed('/persona/25', 'GET', ALLOWED_PROXY_PATHS)).toBe(true);
|
|
282
|
+
expect(
|
|
283
|
+
isPathAllowed('/chat/create-chat-session', 'POST', ALLOWED_PROXY_PATHS),
|
|
284
|
+
).toBe(true);
|
|
285
|
+
expect(
|
|
286
|
+
isPathAllowed('/chat/send-message', 'POST', ALLOWED_PROXY_PATHS),
|
|
287
|
+
).toBe(true);
|
|
288
|
+
expect(
|
|
289
|
+
isPathAllowed('/chat/send-chat-message', 'POST', ALLOWED_PROXY_PATHS),
|
|
290
|
+
).toBe(true);
|
|
291
|
+
expect(
|
|
292
|
+
isPathAllowed(
|
|
293
|
+
'/chat/create-chat-message-feedback',
|
|
294
|
+
'POST',
|
|
295
|
+
ALLOWED_PROXY_PATHS,
|
|
296
|
+
),
|
|
297
|
+
).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('rejects allowed paths with wrong methods', () => {
|
|
301
|
+
expect(isPathAllowed('/persona', 'POST', ALLOWED_PROXY_PATHS)).toBe(false);
|
|
302
|
+
expect(
|
|
303
|
+
isPathAllowed('/chat/send-message', 'GET', ALLOWED_PROXY_PATHS),
|
|
304
|
+
).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('rejects disallowed paths', () => {
|
|
308
|
+
expect(
|
|
309
|
+
isPathAllowed('/admin/users/delete', 'POST', ALLOWED_PROXY_PATHS),
|
|
310
|
+
).toBe(false);
|
|
311
|
+
expect(isPathAllowed('/../../etc/passwd', 'GET', ALLOWED_PROXY_PATHS)).toBe(
|
|
312
|
+
false,
|
|
313
|
+
);
|
|
314
|
+
expect(isPathAllowed('/api/debug', 'GET', ALLOWED_PROXY_PATHS)).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('strips query strings before comparison', () => {
|
|
318
|
+
expect(
|
|
319
|
+
isPathAllowed(
|
|
320
|
+
'/persona?include_deleted=false',
|
|
321
|
+
'GET',
|
|
322
|
+
ALLOWED_PROXY_PATHS,
|
|
323
|
+
),
|
|
324
|
+
).toBe(true);
|
|
325
|
+
expect(
|
|
326
|
+
isPathAllowed('/persona/25?fields=name', 'GET', ALLOWED_PROXY_PATHS),
|
|
327
|
+
).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('rejects non-numeric persona IDs', () => {
|
|
331
|
+
expect(isPathAllowed('/persona/abc', 'GET', ALLOWED_PROXY_PATHS)).toBe(
|
|
332
|
+
false,
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('isPathAllowed with _rq paths', () => {
|
|
338
|
+
it('allows related question paths via shared allowlist', () => {
|
|
339
|
+
expect(
|
|
340
|
+
isPathAllowed('/chat/create-chat-session', 'POST', ALLOWED_PROXY_PATHS),
|
|
341
|
+
).toBe(true);
|
|
342
|
+
expect(
|
|
343
|
+
isPathAllowed('/chat/send-message', 'POST', ALLOWED_PROXY_PATHS),
|
|
344
|
+
).toBe(true);
|
|
345
|
+
expect(
|
|
346
|
+
isPathAllowed('/chat/send-chat-message', 'POST', ALLOWED_PROXY_PATHS),
|
|
347
|
+
).toBe(true);
|
|
348
|
+
});
|
|
237
349
|
});
|