@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 +50 -0
- package/changelog/v1.json +18 -0
- package/next.config.ts +1 -0
- package/package.json +2 -2
- package/packages/model-bank/src/aiModels/qwen.ts +25 -0
- package/packages/model-runtime/src/providers/qwen/createImage.ts +1 -1
- package/packages/utils/src/server/correctOIDCUrl.test.ts +466 -0
- package/packages/utils/src/server/correctOIDCUrl.ts +15 -7
- package/packages/utils/src/server/validateRedirectHost.ts +68 -0
- package/packages/utils/src/toolManifest.ts +12 -9
- package/src/app/(backend)/webapi/chat/vertexai/route.ts +1 -1
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
|
+
[](#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
|
+
[](#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.
|
|
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.
|
|
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 是一款通用图像生成模型,支持多种艺术风格,尤其擅长复杂文本渲染,特别是中英文文本渲染。模型支持多行布局、段落级文本生成以及细粒度细节刻画,可实现复杂的图文混合布局设计。',
|
|
@@ -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
|
-
*
|
|
8
|
-
* @param req - Next.js
|
|
9
|
-
* @param url -
|
|
10
|
-
* @returns
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
+
const convertor = new OpenAPIConvertor(openapiJson);
|
|
116
|
+
const openAPIs = await convertor.convertOpenAPIToPluginSchema();
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
data.api = [...data.api, ...openAPIs];
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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;
|