@lobehub/chat 1.130.0 → 1.131.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/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 1.131.0](https://github.com/lobehub/lobe-chat/compare/v1.130.1...v1.131.0)
6
+
7
+ <sup>Released on **2025-09-19**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **misc**: Qwen provider add qwen-image-edit model support.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **misc**: Qwen provider add qwen-image-edit model support, closes [#9311](https://github.com/lobehub/lobe-chat/issues/9311) ([a0074fc](https://github.com/lobehub/lobe-chat/commit/a0074fc))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ### [Version 1.130.1](https://github.com/lobehub/lobe-chat/compare/v1.130.0...v1.130.1)
31
+
32
+ <sup>Released on **2025-09-18**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **misc**: Fix oidc open direct issue.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **misc**: Fix oidc open direct issue, closes [#9315](https://github.com/lobehub/lobe-chat/issues/9315) ([70f52a3](https://github.com/lobehub/lobe-chat/commit/70f52a3))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ## [Version 1.130.0](https://github.com/lobehub/lobe-chat/compare/v1.129.4...v1.130.0)
6
56
 
7
57
  <sup>Released on **2025-09-18**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "features": [
5
+ "Qwen provider add qwen-image-edit model support."
6
+ ]
7
+ },
8
+ "date": "2025-09-19",
9
+ "version": "1.131.0"
10
+ },
11
+ {
12
+ "children": {
13
+ "fixes": [
14
+ "Fix oidc open direct issue."
15
+ ]
16
+ },
17
+ "date": "2025-09-18",
18
+ "version": "1.130.1"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "features": [
package/next.config.ts CHANGED
@@ -271,6 +271,7 @@ const nextConfig: NextConfig = {
271
271
  // when external packages in dev mode with turbopack, this config will lead to bundle error
272
272
  serverExternalPackages: isProd ? ['@electric-sql/pglite'] : undefined,
273
273
  transpilePackages: ['pdfjs-dist', 'mermaid'],
274
+
274
275
  typescript: {
275
276
  ignoreBuildErrors: true,
276
277
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.130.0",
3
+ "version": "1.131.0",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -158,7 +158,7 @@
158
158
  "@lobehub/charts": "^2.1.2",
159
159
  "@lobehub/chat-plugin-sdk": "^1.32.4",
160
160
  "@lobehub/chat-plugins-gateway": "^1.9.0",
161
- "@lobehub/editor": "^1.8.5",
161
+ "@lobehub/editor": "^1.9.2",
162
162
  "@lobehub/icons": "^2.32.2",
163
163
  "@lobehub/market-sdk": "^0.22.7",
164
164
  "@lobehub/tts": "^2.0.1",
@@ -748,6 +748,7 @@ const qwenChatModels: AIChatModelCard[] = [
748
748
  id: 'qwen3-max-preview',
749
749
  maxOutput: 32_768,
750
750
  organization: 'Qwen',
751
+ /* eslint-disable sort-keys-fix/sort-keys-fix */
751
752
  pricing: {
752
753
  currency: 'CNY',
753
754
  units: [
@@ -792,6 +793,7 @@ const qwenChatModels: AIChatModelCard[] = [
792
793
  },
793
794
  ],
794
795
  },
796
+ /* eslint-enable sort-keys-fix/sort-keys-fix */
795
797
  releasedAt: '2025-09-05',
796
798
  settings: {
797
799
  searchImpl: 'params',
@@ -1558,6 +1560,29 @@ const qwenChatModels: AIChatModelCard[] = [
1558
1560
  ];
1559
1561
 
1560
1562
  const qwenImageModels: AIImageModelCard[] = [
1563
+ {
1564
+ description:
1565
+ 'Qwen Image Edit 是一款图生图模型,支持基于输入图像和文本提示进行图像编辑和修改,能够根据用户需求对原图进行精准调整和创意改造。',
1566
+ displayName: 'Qwen Image Edit',
1567
+ enabled: true,
1568
+ id: 'qwen-image-edit',
1569
+ organization: 'Qwen',
1570
+ parameters: {
1571
+ imageUrl: {
1572
+ default: '',
1573
+ },
1574
+ prompt: {
1575
+ default: '',
1576
+ },
1577
+ seed: { default: null },
1578
+ },
1579
+ pricing: {
1580
+ currency: 'USD',
1581
+ units: [{ name: 'imageGeneration', rate: 0.041, strategy: 'fixed', unit: 'image' }],
1582
+ },
1583
+ releasedAt: '2025-09-18',
1584
+ type: 'image',
1585
+ },
1561
1586
  {
1562
1587
  description:
1563
1588
  'Qwen-Image 是一款通用图像生成模型,支持多种艺术风格,尤其擅长复杂文本渲染,特别是中英文文本渲染。模型支持多行布局、段落级文本生成以及细粒度细节刻画,可实现复杂的图文混合布局设计。',
@@ -114,7 +114,7 @@ async function createImageEdit(
114
114
  },
115
115
  model,
116
116
  parameters: {
117
- // watermark defaults to false (no watermark) unless explicitly requested
117
+ ...(typeof params.seed === 'number' ? { seed: params.seed } : {}),
118
118
  },
119
119
  }),
120
120
  headers: {
@@ -0,0 +1,466 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { correctOIDCUrl } from './correctOIDCUrl';
5
+
6
+ describe('correctOIDCUrl', () => {
7
+ let mockRequest: NextRequest;
8
+ let originalAppUrl: string | undefined;
9
+
10
+ beforeEach(() => {
11
+ vi.clearAllMocks();
12
+ // Store original APP_URL and set default for tests
13
+ originalAppUrl = process.env.APP_URL;
14
+ process.env.APP_URL = 'https://example.com';
15
+
16
+ // Create a mock request with a mutable headers property
17
+ mockRequest = {
18
+ headers: {
19
+ get: vi.fn(),
20
+ },
21
+ } as unknown as NextRequest;
22
+ });
23
+
24
+ afterEach(() => {
25
+ // Restore original APP_URL
26
+ if (originalAppUrl === undefined) {
27
+ delete process.env.APP_URL;
28
+ } else {
29
+ process.env.APP_URL = originalAppUrl;
30
+ }
31
+ });
32
+
33
+ describe('when no forwarded headers are present', () => {
34
+ it('should return original URL when host matches and protocol is correct', () => {
35
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
36
+ if (header === 'host') return 'example.com';
37
+ return null;
38
+ });
39
+
40
+ const originalUrl = new URL('https://example.com/auth/callback');
41
+ const result = correctOIDCUrl(mockRequest, originalUrl);
42
+
43
+ expect(result.toString()).toBe('https://example.com/auth/callback');
44
+ expect(result).toBe(originalUrl); // Should return the same object
45
+ });
46
+
47
+ it('should correct localhost URLs to request host preserving port', () => {
48
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
49
+ if (header === 'host') return 'example.com';
50
+ return null;
51
+ });
52
+
53
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
54
+ const result = correctOIDCUrl(mockRequest, originalUrl);
55
+
56
+ expect(result.toString()).toBe('http://example.com:3000/auth/callback');
57
+ expect(result.host).toBe('example.com:3000');
58
+ expect(result.hostname).toBe('example.com');
59
+ expect(result.port).toBe('3000');
60
+ expect(result.protocol).toBe('http:');
61
+ });
62
+
63
+ it('should correct 127.0.0.1 URLs to request host preserving port', () => {
64
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
65
+ if (header === 'host') return 'example.com';
66
+ return null;
67
+ });
68
+
69
+ const originalUrl = new URL('http://127.0.0.1:3000/auth/callback');
70
+ const result = correctOIDCUrl(mockRequest, originalUrl);
71
+
72
+ expect(result.toString()).toBe('http://example.com:3000/auth/callback');
73
+ expect(result.host).toBe('example.com:3000');
74
+ expect(result.hostname).toBe('example.com');
75
+ });
76
+
77
+ it('should correct 0.0.0.0 URLs to request host preserving port', () => {
78
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
79
+ if (header === 'host') return 'example.com';
80
+ return null;
81
+ });
82
+
83
+ const originalUrl = new URL('http://0.0.0.0:3000/auth/callback');
84
+ const result = correctOIDCUrl(mockRequest, originalUrl);
85
+
86
+ expect(result.toString()).toBe('http://example.com:3000/auth/callback');
87
+ expect(result.host).toBe('example.com:3000');
88
+ expect(result.hostname).toBe('example.com');
89
+ });
90
+
91
+ it('should correct mismatched hostnames', () => {
92
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
93
+ if (header === 'host') return 'example.com';
94
+ return null;
95
+ });
96
+
97
+ const originalUrl = new URL('https://different.com/auth/callback');
98
+ const result = correctOIDCUrl(mockRequest, originalUrl);
99
+
100
+ expect(result.toString()).toBe('https://example.com/auth/callback');
101
+ expect(result.host).toBe('example.com');
102
+ });
103
+
104
+ it('should handle request host with port when correcting localhost', () => {
105
+ process.env.APP_URL = 'https://example.com:8080';
106
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
107
+ if (header === 'host') return 'example.com:8080';
108
+ return null;
109
+ });
110
+
111
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
112
+ const result = correctOIDCUrl(mockRequest, originalUrl);
113
+
114
+ expect(result.toString()).toBe('http://example.com:8080/auth/callback');
115
+ expect(result.host).toBe('example.com:8080');
116
+ expect(result.hostname).toBe('example.com');
117
+ expect(result.port).toBe('8080');
118
+ });
119
+ });
120
+
121
+ describe('when x-forwarded-host header is present', () => {
122
+ it('should use x-forwarded-host over host header', () => {
123
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
124
+ if (header === 'host') return 'internal.com';
125
+ if (header === 'x-forwarded-host') return 'proxy.example.com';
126
+ return null;
127
+ });
128
+
129
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
130
+ const result = correctOIDCUrl(mockRequest, originalUrl);
131
+
132
+ expect(result.toString()).toBe('http://proxy.example.com:3000/auth/callback');
133
+ expect(result.host).toBe('proxy.example.com:3000');
134
+ expect(result.hostname).toBe('proxy.example.com');
135
+ });
136
+
137
+ it('should preserve path and query parameters', () => {
138
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
139
+ if (header === 'host') return 'internal.com';
140
+ if (header === 'x-forwarded-host') return 'proxy.example.com';
141
+ return null;
142
+ });
143
+
144
+ const originalUrl = new URL('http://localhost:3000/auth/callback?code=123&state=abc');
145
+ const result = correctOIDCUrl(mockRequest, originalUrl);
146
+
147
+ expect(result.toString()).toBe(
148
+ 'http://proxy.example.com:3000/auth/callback?code=123&state=abc',
149
+ );
150
+ expect(result.pathname).toBe('/auth/callback');
151
+ expect(result.search).toBe('?code=123&state=abc');
152
+ });
153
+ });
154
+
155
+ describe('when x-forwarded-proto header is present', () => {
156
+ it('should use x-forwarded-proto for protocol', () => {
157
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
158
+ if (header === 'host') return 'example.com';
159
+ if (header === 'x-forwarded-proto') return 'https';
160
+ return null;
161
+ });
162
+
163
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
164
+ const result = correctOIDCUrl(mockRequest, originalUrl);
165
+
166
+ expect(result.toString()).toBe('https://example.com:3000/auth/callback');
167
+ expect(result.protocol).toBe('https:');
168
+ });
169
+
170
+ it('should use x-forwarded-protocol as fallback', () => {
171
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
172
+ if (header === 'host') return 'example.com';
173
+ if (header === 'x-forwarded-protocol') return 'https';
174
+ return null;
175
+ });
176
+
177
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
178
+ const result = correctOIDCUrl(mockRequest, originalUrl);
179
+
180
+ expect(result.toString()).toBe('https://example.com:3000/auth/callback');
181
+ expect(result.protocol).toBe('https:');
182
+ });
183
+
184
+ it('should prioritize x-forwarded-proto over x-forwarded-protocol', () => {
185
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
186
+ if (header === 'host') return 'example.com';
187
+ if (header === 'x-forwarded-proto') return 'https';
188
+ if (header === 'x-forwarded-protocol') return 'http';
189
+ return null;
190
+ });
191
+
192
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
193
+ const result = correctOIDCUrl(mockRequest, originalUrl);
194
+
195
+ expect(result.toString()).toBe('https://example.com:3000/auth/callback');
196
+ expect(result.protocol).toBe('https:');
197
+ });
198
+ });
199
+
200
+ describe('protocol inference when no forwarded protocol', () => {
201
+ it('should infer https when original URL uses https', () => {
202
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
203
+ if (header === 'host') return 'example.com';
204
+ return null;
205
+ });
206
+
207
+ const originalUrl = new URL('https://localhost:3000/auth/callback');
208
+ const result = correctOIDCUrl(mockRequest, originalUrl);
209
+
210
+ expect(result.toString()).toBe('https://example.com:3000/auth/callback');
211
+ expect(result.protocol).toBe('https:');
212
+ });
213
+
214
+ it('should default to http when original URL uses http', () => {
215
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
216
+ if (header === 'host') return 'example.com';
217
+ return null;
218
+ });
219
+
220
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
221
+ const result = correctOIDCUrl(mockRequest, originalUrl);
222
+
223
+ expect(result.toString()).toBe('http://example.com:3000/auth/callback');
224
+ expect(result.protocol).toBe('http:');
225
+ });
226
+ });
227
+
228
+ describe('edge cases', () => {
229
+ it('should return original URL when host is null', () => {
230
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
231
+ if (header === 'host') return null;
232
+ return null;
233
+ });
234
+
235
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
236
+ const result = correctOIDCUrl(mockRequest, originalUrl);
237
+
238
+ expect(result).toBe(originalUrl);
239
+ expect(result.toString()).toBe('http://localhost:3000/auth/callback');
240
+ });
241
+
242
+ it('should return original URL when host is "null" string', () => {
243
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
244
+ if (header === 'host') return 'null';
245
+ return null;
246
+ });
247
+
248
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
249
+ const result = correctOIDCUrl(mockRequest, originalUrl);
250
+
251
+ expect(result).toBe(originalUrl);
252
+ expect(result.toString()).toBe('http://localhost:3000/auth/callback');
253
+ });
254
+
255
+ it('should return original URL when no host header is present', () => {
256
+ (mockRequest.headers.get as any).mockImplementation(() => null);
257
+
258
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
259
+ const result = correctOIDCUrl(mockRequest, originalUrl);
260
+
261
+ expect(result).toBe(originalUrl);
262
+ expect(result.toString()).toBe('http://localhost:3000/auth/callback');
263
+ });
264
+
265
+ it('should handle URL construction errors gracefully', () => {
266
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
267
+ if (header === 'host') return 'example.com';
268
+ return null;
269
+ });
270
+
271
+ const originalUrl = new URL(
272
+ 'http://localhost:3000/auth/callback?redirect=http://example.com',
273
+ );
274
+
275
+ // Spy on URL constructor to simulate an error on correction
276
+ const urlSpy = vi.spyOn(global, 'URL');
277
+ urlSpy.mockImplementationOnce((url: string | URL, base?: string | URL) => new URL(url, base)); // First call succeeds (original)
278
+ urlSpy.mockImplementationOnce(() => {
279
+ throw new Error('Invalid URL');
280
+ }); // Second call fails (correction)
281
+
282
+ const result = correctOIDCUrl(mockRequest, originalUrl);
283
+
284
+ // Should return original URL when correction fails
285
+ expect(result).toBe(originalUrl);
286
+ expect(result.toString()).toBe(
287
+ 'http://localhost:3000/auth/callback?redirect=http://example.com',
288
+ );
289
+
290
+ urlSpy.mockRestore();
291
+ });
292
+ });
293
+
294
+ describe('complex scenarios', () => {
295
+ it('should handle complete proxy scenario with all headers', () => {
296
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
297
+ if (header === 'host') return 'internal-service:3000';
298
+ if (header === 'x-forwarded-host') return 'api.example.com';
299
+ if (header === 'x-forwarded-proto') return 'https';
300
+ return null;
301
+ });
302
+
303
+ const originalUrl = new URL('http://localhost:8080/api/auth/callback?code=xyz&state=def');
304
+ const result = correctOIDCUrl(mockRequest, originalUrl);
305
+
306
+ expect(result.toString()).toBe(
307
+ 'https://api.example.com:8080/api/auth/callback?code=xyz&state=def',
308
+ );
309
+ expect(result.protocol).toBe('https:');
310
+ expect(result.host).toBe('api.example.com:8080');
311
+ expect(result.hostname).toBe('api.example.com');
312
+ expect(result.pathname).toBe('/api/auth/callback');
313
+ expect(result.search).toBe('?code=xyz&state=def');
314
+ });
315
+
316
+ it('should preserve URL hash fragments', () => {
317
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
318
+ if (header === 'host') return 'example.com';
319
+ if (header === 'x-forwarded-proto') return 'https';
320
+ return null;
321
+ });
322
+
323
+ const originalUrl = new URL('http://localhost:3000/auth/callback#access_token=123');
324
+ const result = correctOIDCUrl(mockRequest, originalUrl);
325
+
326
+ expect(result.toString()).toBe('https://example.com:3000/auth/callback#access_token=123');
327
+ expect(result.hash).toBe('#access_token=123');
328
+ });
329
+
330
+ it('should reject forwarded host with non-standard port for security', () => {
331
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
332
+ if (header === 'host') return 'internal.com:3000';
333
+ if (header === 'x-forwarded-host') return 'example.com:8443';
334
+ if (header === 'x-forwarded-proto') return 'https';
335
+ return null;
336
+ });
337
+
338
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
339
+ const result = correctOIDCUrl(mockRequest, originalUrl);
340
+
341
+ // Should return original URL because example.com:8443 doesn't match configured APP_URL (https://example.com)
342
+ expect(result).toBe(originalUrl);
343
+ expect(result.toString()).toBe('http://localhost:3000/auth/callback');
344
+ });
345
+
346
+ it('should not need correction when URL hostname matches actual host', () => {
347
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
348
+ if (header === 'host') return 'example.com';
349
+ return null;
350
+ });
351
+
352
+ const originalUrl = new URL('http://example.com/auth/callback');
353
+ const result = correctOIDCUrl(mockRequest, originalUrl);
354
+
355
+ expect(result).toBe(originalUrl); // Should return the same object
356
+ expect(result.toString()).toBe('http://example.com/auth/callback');
357
+ });
358
+ });
359
+
360
+ describe('Open Redirect protection', () => {
361
+ it('should prevent redirection to malicious external domains', () => {
362
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
363
+ if (header === 'host') return 'malicious.com';
364
+ return null;
365
+ });
366
+
367
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
368
+ const result = correctOIDCUrl(mockRequest, originalUrl);
369
+
370
+ // Should return original URL and not redirect to malicious.com
371
+ expect(result).toBe(originalUrl);
372
+ expect(result.toString()).toBe('http://localhost:3000/auth/callback');
373
+ });
374
+
375
+ it('should allow redirection to configured domain (example.com)', () => {
376
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
377
+ if (header === 'host') return 'example.com';
378
+ return null;
379
+ });
380
+
381
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
382
+ const result = correctOIDCUrl(mockRequest, originalUrl);
383
+
384
+ // Should allow correction to example.com (configured in APP_URL)
385
+ expect(result.toString()).toBe('http://example.com:3000/auth/callback');
386
+ expect(result.host).toBe('example.com:3000');
387
+ });
388
+
389
+ it('should allow redirection to subdomains of configured domain', () => {
390
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
391
+ if (header === 'host') return 'api.example.com';
392
+ return null;
393
+ });
394
+
395
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
396
+ const result = correctOIDCUrl(mockRequest, originalUrl);
397
+
398
+ // Should allow correction to subdomain of example.com
399
+ expect(result.toString()).toBe('http://api.example.com:3000/auth/callback');
400
+ expect(result.host).toBe('api.example.com:3000');
401
+ });
402
+
403
+ it('should prevent redirection via x-forwarded-host to malicious domains', () => {
404
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
405
+ if (header === 'host') return 'example.com'; // Trusted internal host
406
+ if (header === 'x-forwarded-host') return 'evil.com'; // Malicious forwarded host
407
+ return null;
408
+ });
409
+
410
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
411
+ const result = correctOIDCUrl(mockRequest, originalUrl);
412
+
413
+ // Should return original URL and not redirect to evil.com
414
+ expect(result).toBe(originalUrl);
415
+ expect(result.toString()).toBe('http://localhost:3000/auth/callback');
416
+ });
417
+
418
+ it('should allow localhost in development environment', () => {
419
+ // Set APP_URL to localhost for development testing
420
+ process.env.APP_URL = 'http://localhost:3000';
421
+
422
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
423
+ if (header === 'host') return 'localhost:8080';
424
+ return null;
425
+ });
426
+
427
+ const originalUrl = new URL('http://127.0.0.1:3000/auth/callback');
428
+ const result = correctOIDCUrl(mockRequest, originalUrl);
429
+
430
+ // Should allow correction to localhost in dev environment
431
+ expect(result.toString()).toBe('http://localhost:8080/auth/callback');
432
+ expect(result.host).toBe('localhost:8080');
433
+ });
434
+
435
+ it('should prevent redirection when APP_URL is not configured', () => {
436
+ // Remove APP_URL to simulate missing configuration
437
+ delete process.env.APP_URL;
438
+
439
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
440
+ if (header === 'host') return 'any-domain.com';
441
+ return null;
442
+ });
443
+
444
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
445
+ const result = correctOIDCUrl(mockRequest, originalUrl);
446
+
447
+ // Should return original URL when APP_URL is not configured
448
+ expect(result).toBe(originalUrl);
449
+ expect(result.toString()).toBe('http://localhost:3000/auth/callback');
450
+ });
451
+
452
+ it('should handle domains that look like subdomains but are not', () => {
453
+ (mockRequest.headers.get as any).mockImplementation((header: string) => {
454
+ if (header === 'host') return 'fakeexample.com'; // Not a subdomain of example.com
455
+ return null;
456
+ });
457
+
458
+ const originalUrl = new URL('http://localhost:3000/auth/callback');
459
+ const result = correctOIDCUrl(mockRequest, originalUrl);
460
+
461
+ // Should prevent redirection to fake domain
462
+ expect(result).toBe(originalUrl);
463
+ expect(result.toString()).toBe('http://localhost:3000/auth/callback');
464
+ });
465
+ });
466
+ });
@@ -1,13 +1,15 @@
1
1
  import debug from 'debug';
2
2
  import { NextRequest } from 'next/server';
3
3
 
4
+ import { validateRedirectHost } from './validateRedirectHost';
5
+
4
6
  const log = debug('lobe-oidc:correctOIDCUrl');
5
7
 
6
8
  /**
7
- * 修复 OIDC 重定向 URL 在代理环境下的问题
8
- * @param req - Next.js 请求对象
9
- * @param url - 要修复的 URL 对象
10
- * @returns 修复后的 URL 对象
9
+ * Fix OIDC redirect URL issues in proxy environments
10
+ * @param req - Next.js request object
11
+ * @param url - URL object to fix
12
+ * @returns Fixed URL object
11
13
  */
12
14
  export const correctOIDCUrl = (req: NextRequest, url: URL): URL => {
13
15
  const requestHost = req.headers.get('host');
@@ -23,17 +25,23 @@ export const correctOIDCUrl = (req: NextRequest, url: URL): URL => {
23
25
  forwardedProto,
24
26
  );
25
27
 
26
- // 确定实际的主机名和协议,提供后备值
28
+ // Determine actual hostname and protocol with fallback values
27
29
  const actualHost = forwardedHost || requestHost;
28
30
  const actualProto = forwardedProto || (url.protocol === 'https:' ? 'https' : 'http');
29
31
 
30
- // 如果无法确定有效的主机名,直接返回原URL
32
+ // If unable to determine valid hostname, return original URL
31
33
  if (!actualHost || actualHost === 'null') {
32
34
  log('Warning: Cannot determine valid host, returning original URL');
33
35
  return url;
34
36
  }
35
37
 
36
- // 如果 URL 指向本地地址,或者主机名与实际请求主机不匹配,则修正 URL
38
+ // Validate target host for security, prevent Open Redirect attacks
39
+ if (!validateRedirectHost(actualHost)) {
40
+ log('Warning: Target host %s failed validation, returning original URL', actualHost);
41
+ return url;
42
+ }
43
+
44
+ // Correct URL if it points to localhost or hostname doesn't match actual request host
37
45
  const needsCorrection =
38
46
  url.hostname === 'localhost' ||
39
47
  url.hostname === '127.0.0.1' ||
@@ -0,0 +1,68 @@
1
+ import debug from 'debug';
2
+
3
+ const log = debug('lobe-oidc:validateRedirectHost');
4
+
5
+ /**
6
+ * Validate if redirect host is in the allowed whitelist
7
+ * Prevent Open Redirect attacks
8
+ */
9
+ export const validateRedirectHost = (targetHost: string): boolean => {
10
+ if (!targetHost || targetHost === 'null') {
11
+ log('Invalid target host: %s', targetHost);
12
+ return false;
13
+ }
14
+
15
+ // Get configured APP_URL as base domain
16
+ const appUrl = process.env.APP_URL;
17
+ if (!appUrl) {
18
+ log('Warning: APP_URL not configured, rejecting redirect to: %s', targetHost);
19
+ return false;
20
+ }
21
+
22
+ try {
23
+ const appUrlObj = new URL(appUrl);
24
+ const appHost = appUrlObj.host;
25
+
26
+ log('Validating target host: %s against app host: %s', targetHost, appHost);
27
+
28
+ // Exact match
29
+ if (targetHost === appHost) {
30
+ log('Host validation passed: exact match');
31
+ return true;
32
+ }
33
+
34
+ // Allow localhost and local addresses (development environment)
35
+ const isLocalhost =
36
+ targetHost === 'localhost' ||
37
+ targetHost.startsWith('localhost:') ||
38
+ targetHost === '127.0.0.1' ||
39
+ targetHost.startsWith('127.0.0.1:') ||
40
+ targetHost === '0.0.0.0' ||
41
+ targetHost.startsWith('0.0.0.0:');
42
+
43
+ if (
44
+ isLocalhost &&
45
+ (appHost.includes('localhost') ||
46
+ appHost.includes('127.0.0.1') ||
47
+ appHost.includes('0.0.0.0'))
48
+ ) {
49
+ log('Host validation passed: localhost environment');
50
+ return true;
51
+ }
52
+
53
+ // Check if it's a subdomain of the configured domain
54
+ const appDomain = appHost.split(':')[0]; // Remove port number
55
+ const targetDomain = targetHost.split(':')[0]; // Remove port number
56
+
57
+ if (targetDomain.endsWith('.' + appDomain)) {
58
+ log('Host validation passed: subdomain of %s', appDomain);
59
+ return true;
60
+ }
61
+
62
+ console.error('Host validation failed: %s is not allowed', targetHost);
63
+ return false;
64
+ } catch (error) {
65
+ console.error('Error parsing APP_URL %s: %O', appUrl, error);
66
+ return false;
67
+ }
68
+ };
@@ -1,4 +1,4 @@
1
- import { ChatCompletionTool , OpenAIPluginManifest } from '@lobechat/types';
1
+ import { ChatCompletionTool, OpenAIPluginManifest } from '@lobechat/types';
2
2
  import { LobeChatPluginManifest, pluginManifestSchema } from '@lobehub/chat-plugin-sdk';
3
3
  import { uniqBy } from 'lodash-es';
4
4
 
@@ -107,17 +107,20 @@ export const getToolManifest = async (
107
107
  if (parser.data.openapi) {
108
108
  const openapiJson = await fetchJSON(parser.data.openapi, useProxy);
109
109
 
110
- try {
111
- const { OpenAPIConvertor } = await import('@lobehub/chat-plugin-sdk/openapi');
110
+ // avoid https://github.com/lobehub/lobe-chat/issues/9059
111
+ if (typeof window !== 'undefined') {
112
+ try {
113
+ const { OpenAPIConvertor } = await import('@lobehub/chat-plugin-sdk/openapi');
112
114
 
113
- const convertor = new OpenAPIConvertor(openapiJson);
114
- const openAPIs = await convertor.convertOpenAPIToPluginSchema();
115
+ const convertor = new OpenAPIConvertor(openapiJson);
116
+ const openAPIs = await convertor.convertOpenAPIToPluginSchema();
115
117
 
116
- data.api = [...data.api, ...openAPIs];
118
+ data.api = [...data.api, ...openAPIs];
117
119
 
118
- data.settings = await convertor.convertAuthToSettingsSchema(data.settings);
119
- } catch (error) {
120
- throw new TypeError('openAPIInvalid', { cause: error });
120
+ data.settings = await convertor.convertAuthToSettingsSchema(data.settings);
121
+ } catch (error) {
122
+ throw new TypeError('openAPIInvalid', { cause: error });
123
+ }
121
124
  }
122
125
  }
123
126
 
@@ -16,7 +16,7 @@ export const maxDuration = 300;
16
16
  // setGlobalDispatcher(new ProxyAgent({ uri: process.env.HTTP_PROXY_URL }));
17
17
  // }
18
18
 
19
- export const POST = checkAuth(async (req: Request, { jwtPayload }) =>
19
+ export const POST: any = checkAuth(async (req: Request, { jwtPayload }) =>
20
20
  UniverseRoute(req, {
21
21
  createRuntime: () => {
22
22
  const googleAuthStr = jwtPayload.apiKey ?? process.env.VERTEXAI_CREDENTIALS ?? undefined;