@eeacms/volto-eea-chatbot 2.0.3 → 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 CHANGED
@@ -4,7 +4,27 @@ 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.3](https://github.com/eea/volto-eea-chatbot/compare/2.0.2...2.0.3) - 18 May 2026
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)]
17
+ ### [2.0.4](https://github.com/eea/volto-eea-chatbot/compare/2.0.3...2.0.4) - 28 May 2026
18
+
19
+ #### :bug: Bug Fixes
20
+
21
+ - fix: Remove console.logs [Alin Voinea - [`fed3b08`](https://github.com/eea/volto-eea-chatbot/commit/fed3b086b35fb74bbf03d39d679c8167d3a2e29b)]
22
+
23
+ #### :hammer_and_wrench: Others
24
+
25
+ - Revert "fixed summary while streaming" [Alin Voinea - [`9c59b72`](https://github.com/eea/volto-eea-chatbot/commit/9c59b7210bd1dc49b61ba90ae7e218b858ebc360)]
26
+ - fixed summary while streaming [Zoltan Szabo - [`3ff7468`](https://github.com/eea/volto-eea-chatbot/commit/3ff74680c716be39de75f5dee2cdf92096edfd5a)]
27
+ ### [2.0.3](https://github.com/eea/volto-eea-chatbot/compare/2.0.2...2.0.3) - 19 May 2026
8
28
 
9
29
  #### :rocket: New Features
10
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-eea-chatbot",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "@eeacms/volto-eea-chatbot: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -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('calls getVerifyClaimResponse and sends response on success', async () => {
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
- genMod.getVerifyClaimResponse = jest
100
+ const mockGetVerifyClaimResponse = jest
57
101
  .fn()
58
102
  .mockResolvedValue({ claims: [], segments: {} });
59
103
 
60
- await middlewareMod(req, res, next);
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
- // It may send error if env vars weren't set before module load,
63
- // but we verify the middleware doesn't crash
64
- expect(res.send).toHaveBeenCalled();
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;
@@ -186,12 +211,12 @@ async function send_onyx_request(
186
211
  options.body = JSON.stringify(req.body);
187
212
  }
188
213
 
189
- console.log('[Middleware] Sending request to Onyx:', {
190
- url,
191
- method: req.method,
192
- hasBody: !!req.body,
193
- body: JSON.stringify(req.body, null, 2),
194
- });
214
+ // console.log('[Middleware] Sending request to Onyx:', {
215
+ // url,
216
+ // method: req.method,
217
+ // hasBody: !!req.body,
218
+ // body: JSON.stringify(req.body, null, 2),
219
+ // });
195
220
 
196
221
  const mock_file = is_related_question
197
222
  ? process.env.MOCK_LLM_FILE_PATH_RQ
@@ -220,12 +245,12 @@ async function send_onyx_request(
220
245
  log(`Fetching ${url}`);
221
246
  const response = await fetch(url, options, req.body);
222
247
 
223
- console.log('[Middleware] Received response from Onyx:', {
224
- url,
225
- status: response.status,
226
- statusText: response.statusText,
227
- headers: response.headers.raw(),
228
- });
248
+ // console.log('[Middleware] Received response from Onyx:', {
249
+ // url,
250
+ // status: response.status,
251
+ // statusText: response.statusText,
252
+ // headers: response.headers.raw(),
253
+ // });
229
254
 
230
255
  if (process.env.DUMP_LLM_FILE_PATH && !is_related_question) {
231
256
  const filePath = process.env.DUMP_LLM_FILE_PATH;
@@ -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;
@@ -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
  });