@lobehub/lobehub 2.0.0-next.359 → 2.0.0-next.360
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 +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/packages/utils/src/server/index.ts +0 -2
- package/src/app/(backend)/oidc/callback/desktop/route.ts +18 -7
- package/src/app/(backend)/oidc/consent/route.ts +17 -8
- package/packages/utils/src/server/correctOIDCUrl.test.ts +0 -527
- package/packages/utils/src/server/correctOIDCUrl.ts +0 -134
- package/packages/utils/src/server/validateRedirectHost.test.ts +0 -352
- package/packages/utils/src/server/validateRedirectHost.ts +0 -68
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.360](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.359...v2.0.0-next.360)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2026-01-24**</sup>
|
|
8
|
+
|
|
9
|
+
#### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **misc**: Login success callback url error.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's fixed
|
|
19
|
+
|
|
20
|
+
- **misc**: Login success callback url error, closes [#11763](https://github.com/lobehub/lobe-chat/issues/11763) ([f73435d](https://github.com/lobehub/lobe-chat/commit/f73435d))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
5
30
|
## [Version 2.0.0-next.359](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.358...v2.0.0-next.359)
|
|
6
31
|
|
|
7
32
|
<sup>Released on **2026-01-24**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.360",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent 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",
|
|
@@ -3,22 +3,33 @@ import { type NextRequest, NextResponse, after } from 'next/server';
|
|
|
3
3
|
|
|
4
4
|
import { OAuthHandoffModel } from '@/database/models/oauthHandoff';
|
|
5
5
|
import { serverDB } from '@/database/server';
|
|
6
|
-
import {
|
|
6
|
+
import { appEnv } from '@/envs/app';
|
|
7
7
|
|
|
8
8
|
const log = debug('lobe-oidc:callback:desktop');
|
|
9
9
|
|
|
10
10
|
const errorPathname = '/oauth/callback/error';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* 安全地构建重定向URL
|
|
13
|
+
* 安全地构建重定向URL - 直接使用 APP_URL 作为目标
|
|
14
14
|
*/
|
|
15
15
|
const buildRedirectUrl = (req: NextRequest, pathname: string): URL => {
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
// 使用统一的环境变量管理
|
|
17
|
+
if (appEnv.APP_URL) {
|
|
18
|
+
try {
|
|
19
|
+
const baseUrl = new URL(appEnv.APP_URL);
|
|
20
|
+
baseUrl.pathname = pathname;
|
|
21
|
+
log('Using APP_URL for redirect: %s', baseUrl.toString());
|
|
22
|
+
return baseUrl;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
log('Error parsing APP_URL, using fallback: %O', error);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
19
27
|
|
|
20
|
-
//
|
|
21
|
-
|
|
28
|
+
// 后备方案:使用 req.nextUrl
|
|
29
|
+
log('Warning: APP_URL not configured, using req.nextUrl as fallback');
|
|
30
|
+
const fallbackUrl = req.nextUrl.clone();
|
|
31
|
+
fallbackUrl.pathname = pathname;
|
|
32
|
+
return fallbackUrl;
|
|
22
33
|
};
|
|
23
34
|
|
|
24
35
|
export const GET = async (req: NextRequest) => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getUserAuth } from '@lobechat/utils/server';
|
|
2
2
|
import debug from 'debug';
|
|
3
3
|
import { type NextRequest, NextResponse } from 'next/server';
|
|
4
4
|
|
|
5
|
+
import { appEnv } from '@/envs/app';
|
|
5
6
|
import { OIDCService } from '@/server/services/oidc';
|
|
6
7
|
|
|
7
8
|
const log = debug('lobe-oidc:consent');
|
|
@@ -113,15 +114,23 @@ export async function POST(request: NextRequest) {
|
|
|
113
114
|
const internalRedirectUrlString = await oidcService.getInteractionResult(uid, result);
|
|
114
115
|
log('OIDC Provider internal redirect URL string: %s', internalRedirectUrlString);
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
// 直接使用 APP_URL 作为基础
|
|
118
|
+
if (appEnv.APP_URL) {
|
|
119
|
+
const baseUrl = new URL(appEnv.APP_URL);
|
|
120
|
+
const internalUrl = new URL(internalRedirectUrlString);
|
|
121
|
+
baseUrl.pathname = internalUrl.pathname;
|
|
122
|
+
baseUrl.search = internalUrl.search;
|
|
123
|
+
baseUrl.hash = internalUrl.hash;
|
|
124
|
+
const finalRedirectUrl = baseUrl;
|
|
125
|
+
log('Using APP_URL as base for redirect: %s', finalRedirectUrl.toString());
|
|
126
|
+
return NextResponse.redirect(finalRedirectUrl, {
|
|
127
|
+
status: 303,
|
|
128
|
+
});
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
|
|
131
|
+
// 后备方案:使用原始内部URL
|
|
132
|
+
log('Using internal redirect URL directly: %s', internalRedirectUrlString);
|
|
133
|
+
return NextResponse.redirect(new URL(internalRedirectUrlString), {
|
|
125
134
|
status: 303,
|
|
126
135
|
});
|
|
127
136
|
} catch (error) {
|
|
@@ -1,527 +0,0 @@
|
|
|
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 fall back to host header because example.com:8443 doesn't match configured APP_URL
|
|
342
|
-
expect(result.toString()).toBe('https://internal.com:3000/auth/callback');
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it('should not need correction when URL hostname matches actual host', () => {
|
|
346
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
347
|
-
if (header === 'host') return 'example.com';
|
|
348
|
-
return null;
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
const originalUrl = new URL('http://example.com/auth/callback');
|
|
352
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
353
|
-
|
|
354
|
-
expect(result).toBe(originalUrl); // Should return the same object
|
|
355
|
-
expect(result.toString()).toBe('http://example.com/auth/callback');
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
describe('Open Redirect protection', () => {
|
|
360
|
-
it('should prevent redirection to malicious external domains via x-forwarded-host', () => {
|
|
361
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
362
|
-
if (header === 'host') return 'example.com';
|
|
363
|
-
if (header === 'x-forwarded-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 fall back to host header and not redirect to malicious.com
|
|
371
|
-
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it('should allow redirection to configured domain (example.com)', () => {
|
|
375
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
376
|
-
if (header === 'host') return 'example.com';
|
|
377
|
-
return null;
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
const originalUrl = new URL('http://localhost:3000/auth/callback');
|
|
381
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
382
|
-
|
|
383
|
-
// Should allow correction to example.com (configured in APP_URL)
|
|
384
|
-
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
|
|
385
|
-
expect(result.host).toBe('example.com:3000');
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it('should allow redirection to subdomains of configured domain', () => {
|
|
389
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
390
|
-
if (header === 'host') return 'api.example.com';
|
|
391
|
-
return null;
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
const originalUrl = new URL('http://localhost:3000/auth/callback');
|
|
395
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
396
|
-
|
|
397
|
-
// Should allow correction to subdomain of example.com
|
|
398
|
-
expect(result.toString()).toBe('http://api.example.com:3000/auth/callback');
|
|
399
|
-
expect(result.host).toBe('api.example.com:3000');
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it('should prevent redirection via x-forwarded-host to malicious domains', () => {
|
|
403
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
404
|
-
if (header === 'host') return 'example.com'; // Trusted internal host
|
|
405
|
-
if (header === 'x-forwarded-host') return 'evil.com'; // Malicious forwarded host
|
|
406
|
-
return null;
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
const originalUrl = new URL('http://localhost:3000/auth/callback');
|
|
410
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
411
|
-
|
|
412
|
-
// Should fall back to request host (example.com) and not redirect to evil.com
|
|
413
|
-
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
|
|
414
|
-
expect(result.hostname).not.toBe('evil.com');
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
it('should allow localhost in development environment', () => {
|
|
418
|
-
// Set APP_URL to localhost for development testing
|
|
419
|
-
process.env.APP_URL = 'http://localhost:3000';
|
|
420
|
-
|
|
421
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
422
|
-
if (header === 'host') return 'localhost:8080';
|
|
423
|
-
return null;
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
const originalUrl = new URL('http://127.0.0.1:3000/auth/callback');
|
|
427
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
428
|
-
|
|
429
|
-
// Should allow correction to localhost in dev environment
|
|
430
|
-
expect(result.toString()).toBe('http://localhost:8080/auth/callback');
|
|
431
|
-
expect(result.host).toBe('localhost:8080');
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it('should prevent redirection when APP_URL is not configured', () => {
|
|
435
|
-
// Remove APP_URL to simulate missing configuration
|
|
436
|
-
delete process.env.APP_URL;
|
|
437
|
-
|
|
438
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
439
|
-
if (header === 'host') return 'example.com';
|
|
440
|
-
if (header === 'x-forwarded-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 fall back to host header when APP_URL is not configured and forwarded host is present
|
|
448
|
-
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
it('should handle domains that look like subdomains but are not', () => {
|
|
452
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
453
|
-
if (header === 'host') return 'example.com';
|
|
454
|
-
if (header === 'x-forwarded-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 fall back to host header
|
|
462
|
-
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
it('should reject invalid forwarded protocol', () => {
|
|
466
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
467
|
-
if (header === 'host') return 'example.com';
|
|
468
|
-
if (header === 'x-forwarded-host') return 'example.com';
|
|
469
|
-
if (header === 'x-forwarded-proto') return 'javascript'; // Invalid protocol
|
|
470
|
-
return null;
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
const originalUrl = new URL('http://localhost:3000/auth/callback');
|
|
474
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
475
|
-
|
|
476
|
-
// Should fall back to http protocol from URL
|
|
477
|
-
expect(result.protocol).toBe('http:');
|
|
478
|
-
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it('should handle uppercase in forwarded protocol', () => {
|
|
482
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
483
|
-
if (header === 'host') return 'example.com';
|
|
484
|
-
if (header === 'x-forwarded-host') return 'example.com';
|
|
485
|
-
if (header === 'x-forwarded-proto') return 'HTTPS'; // Uppercase
|
|
486
|
-
return null;
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
const originalUrl = new URL('http://localhost:3000/auth/callback');
|
|
490
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
491
|
-
|
|
492
|
-
// Should normalize to lowercase
|
|
493
|
-
expect(result.protocol).toBe('https:');
|
|
494
|
-
expect(result.toString()).toBe('https://example.com:3000/auth/callback');
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
it('should handle multiple hosts in x-forwarded-host', () => {
|
|
498
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
499
|
-
if (header === 'host') return 'internal.com';
|
|
500
|
-
if (header === 'x-forwarded-host') return 'example.com,attacker.com'; // Multiple hosts
|
|
501
|
-
return null;
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
const originalUrl = new URL('http://localhost:3000/auth/callback');
|
|
505
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
506
|
-
|
|
507
|
-
// Should use the first (leftmost) host
|
|
508
|
-
expect(result.hostname).toBe('example.com');
|
|
509
|
-
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
it('should fall back to request host when forwarded host is invalid', () => {
|
|
513
|
-
(mockRequest.headers.get as any).mockImplementation((header: string) => {
|
|
514
|
-
if (header === 'host') return 'example.com';
|
|
515
|
-
if (header === 'x-forwarded-host') return 'evil.com'; // Invalid
|
|
516
|
-
return null;
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
const originalUrl = new URL('http://localhost:3000/auth/callback');
|
|
520
|
-
const result = correctOIDCUrl(mockRequest, originalUrl);
|
|
521
|
-
|
|
522
|
-
// Should fall back to request host
|
|
523
|
-
expect(result.hostname).toBe('example.com');
|
|
524
|
-
expect(result.toString()).toBe('http://example.com:3000/auth/callback');
|
|
525
|
-
});
|
|
526
|
-
});
|
|
527
|
-
});
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import debug from 'debug';
|
|
2
|
-
import { NextRequest } from 'next/server';
|
|
3
|
-
|
|
4
|
-
import { validateRedirectHost } from './validateRedirectHost';
|
|
5
|
-
|
|
6
|
-
const log = debug('lobe-oidc:correctOIDCUrl');
|
|
7
|
-
|
|
8
|
-
// Allowed protocols for security
|
|
9
|
-
const ALLOWED_PROTOCOLS = ['http', 'https'] as const;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Fix OIDC redirect URL issues in proxy environments
|
|
13
|
-
*
|
|
14
|
-
* This function:
|
|
15
|
-
* 1. Validates protocol against whitelist (http, https only)
|
|
16
|
-
* 2. Handles X-Forwarded-Host with multiple values (RFC 7239)
|
|
17
|
-
* 3. Validates X-Forwarded-Host against APP_URL to prevent open redirect attacks
|
|
18
|
-
* 4. Provides fallback logic for invalid forwarded values
|
|
19
|
-
*
|
|
20
|
-
* Note: Only X-Forwarded-Host is validated, not the Host header. This is because:
|
|
21
|
-
* - X-Forwarded-Host can be injected by attackers
|
|
22
|
-
* - Host header comes from the reverse proxy or direct access, which is trusted
|
|
23
|
-
*
|
|
24
|
-
* @param req - Next.js request object
|
|
25
|
-
* @param url - URL object to fix
|
|
26
|
-
* @returns Fixed URL object
|
|
27
|
-
*/
|
|
28
|
-
export const correctOIDCUrl = (req: NextRequest, url: URL): URL => {
|
|
29
|
-
log('Input URL: %s', url.toString());
|
|
30
|
-
|
|
31
|
-
// Get request headers for origin determination
|
|
32
|
-
const requestHost = req.headers.get('host');
|
|
33
|
-
const forwardedHost = req.headers.get('x-forwarded-host');
|
|
34
|
-
const forwardedProto =
|
|
35
|
-
req.headers.get('x-forwarded-proto') || req.headers.get('x-forwarded-protocol');
|
|
36
|
-
|
|
37
|
-
log(
|
|
38
|
-
'Getting safe origin - requestHost: %s, forwardedHost: %s, forwardedProto: %s',
|
|
39
|
-
requestHost,
|
|
40
|
-
forwardedHost,
|
|
41
|
-
forwardedProto,
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
// Determine actual hostname with fallback values
|
|
45
|
-
// Handle multiple hosts in X-Forwarded-Host (RFC 7239: comma-separated)
|
|
46
|
-
let actualHost = forwardedHost || requestHost;
|
|
47
|
-
if (forwardedHost && forwardedHost.includes(',')) {
|
|
48
|
-
// Take the first (leftmost) host as the original client's request
|
|
49
|
-
actualHost = forwardedHost.split(',')[0]!.trim();
|
|
50
|
-
log('Multiple hosts in X-Forwarded-Host, using first: %s', actualHost);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Determine actual protocol with validation
|
|
54
|
-
// Use URL's protocol as fallback to preserve original behavior
|
|
55
|
-
let actualProto: string | null | undefined = forwardedProto;
|
|
56
|
-
if (actualProto) {
|
|
57
|
-
// Validate protocol is http or https
|
|
58
|
-
const protoLower = actualProto.toLowerCase();
|
|
59
|
-
if (!ALLOWED_PROTOCOLS.includes(protoLower as any)) {
|
|
60
|
-
log('Warning: Invalid protocol %s, ignoring', actualProto);
|
|
61
|
-
actualProto = null;
|
|
62
|
-
} else {
|
|
63
|
-
actualProto = protoLower;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Fallback protocol priority: URL protocol > request.nextUrl.protocol > 'https'
|
|
68
|
-
if (!actualProto) {
|
|
69
|
-
actualProto = url.protocol === 'https:' ? 'https' : 'http';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// If unable to determine valid hostname, return original URL
|
|
73
|
-
if (!actualHost || actualHost === 'null') {
|
|
74
|
-
log('Warning: Cannot determine valid host, returning original URL');
|
|
75
|
-
return url;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Validate only X-Forwarded-Host for security, prevent Open Redirect attacks
|
|
79
|
-
// Host header is trusted (comes from reverse proxy or direct access)
|
|
80
|
-
if (forwardedHost && !validateRedirectHost(actualHost)) {
|
|
81
|
-
log('Warning: X-Forwarded-Host %s failed validation, falling back to request host', actualHost);
|
|
82
|
-
// Try to fall back to request host if forwarded host is invalid
|
|
83
|
-
if (requestHost) {
|
|
84
|
-
actualHost = requestHost;
|
|
85
|
-
} else {
|
|
86
|
-
// No valid host available
|
|
87
|
-
log('Error: No valid host available after validation, returning original URL');
|
|
88
|
-
return url;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Build safe origin
|
|
93
|
-
const safeOrigin = `${actualProto}://${actualHost}`;
|
|
94
|
-
log('Safe origin: %s', safeOrigin);
|
|
95
|
-
|
|
96
|
-
// Parse safe origin to get hostname and protocol
|
|
97
|
-
let safeOriginUrl: URL;
|
|
98
|
-
try {
|
|
99
|
-
safeOriginUrl = new URL(safeOrigin);
|
|
100
|
-
} catch (error) {
|
|
101
|
-
log('Error parsing safe origin: %O', error);
|
|
102
|
-
return url;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Correct URL if it points to localhost or hostname doesn't match actual request host
|
|
106
|
-
const needsCorrection =
|
|
107
|
-
url.hostname === 'localhost' ||
|
|
108
|
-
url.hostname === '127.0.0.1' ||
|
|
109
|
-
url.hostname === '0.0.0.0' ||
|
|
110
|
-
url.hostname !== safeOriginUrl.hostname;
|
|
111
|
-
|
|
112
|
-
if (!needsCorrection) {
|
|
113
|
-
log('URL does not need correction, returning original: %s', url.toString());
|
|
114
|
-
return url;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
log(
|
|
118
|
-
'URL needs correction. Original hostname: %s, correcting to: %s',
|
|
119
|
-
url.hostname,
|
|
120
|
-
safeOriginUrl.hostname,
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
const correctedUrl = new URL(url.toString());
|
|
125
|
-
correctedUrl.protocol = safeOriginUrl.protocol;
|
|
126
|
-
correctedUrl.host = safeOriginUrl.host;
|
|
127
|
-
|
|
128
|
-
log('Corrected URL: %s', correctedUrl.toString());
|
|
129
|
-
return correctedUrl;
|
|
130
|
-
} catch (error) {
|
|
131
|
-
log('Error creating corrected URL, returning original: %O', error);
|
|
132
|
-
return url;
|
|
133
|
-
}
|
|
134
|
-
};
|
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { validateRedirectHost } from './validateRedirectHost';
|
|
4
|
-
|
|
5
|
-
describe('validateRedirectHost', () => {
|
|
6
|
-
let originalAppUrl: string | undefined;
|
|
7
|
-
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
vi.clearAllMocks();
|
|
10
|
-
// Store original APP_URL and set default for tests
|
|
11
|
-
originalAppUrl = process.env.APP_URL;
|
|
12
|
-
process.env.APP_URL = 'https://example.com';
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
// Restore original APP_URL
|
|
17
|
-
if (originalAppUrl === undefined) {
|
|
18
|
-
delete process.env.APP_URL;
|
|
19
|
-
} else {
|
|
20
|
-
process.env.APP_URL = originalAppUrl;
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe('invalid inputs', () => {
|
|
25
|
-
it('should return false when targetHost is empty string', () => {
|
|
26
|
-
const result = validateRedirectHost('');
|
|
27
|
-
expect(result).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('should return false when targetHost is "null" string', () => {
|
|
31
|
-
const result = validateRedirectHost('null');
|
|
32
|
-
expect(result).toBe(false);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('should return false when APP_URL is not configured', () => {
|
|
36
|
-
delete process.env.APP_URL;
|
|
37
|
-
const result = validateRedirectHost('example.com');
|
|
38
|
-
expect(result).toBe(false);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('should return false when APP_URL is malformed', () => {
|
|
42
|
-
process.env.APP_URL = 'not-a-valid-url';
|
|
43
|
-
const result = validateRedirectHost('example.com');
|
|
44
|
-
expect(result).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('exact host match', () => {
|
|
49
|
-
it('should return true when targetHost exactly matches APP_URL host', () => {
|
|
50
|
-
const result = validateRedirectHost('example.com');
|
|
51
|
-
expect(result).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('should return true when targetHost matches APP_URL host with port', () => {
|
|
55
|
-
process.env.APP_URL = 'https://example.com:8080';
|
|
56
|
-
const result = validateRedirectHost('example.com:8080');
|
|
57
|
-
expect(result).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('should return true when targetHost matches APP_URL with different protocols', () => {
|
|
61
|
-
process.env.APP_URL = 'http://example.com';
|
|
62
|
-
const result = validateRedirectHost('example.com');
|
|
63
|
-
expect(result).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('should return false when targetHost port differs from APP_URL', () => {
|
|
67
|
-
process.env.APP_URL = 'https://example.com:8080';
|
|
68
|
-
const result = validateRedirectHost('example.com:9090');
|
|
69
|
-
expect(result).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('localhost validation', () => {
|
|
74
|
-
it('should allow localhost when APP_URL is localhost', () => {
|
|
75
|
-
process.env.APP_URL = 'http://localhost:3000';
|
|
76
|
-
const result = validateRedirectHost('localhost');
|
|
77
|
-
expect(result).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('should allow localhost with port when APP_URL is localhost', () => {
|
|
81
|
-
process.env.APP_URL = 'http://localhost:3000';
|
|
82
|
-
const result = validateRedirectHost('localhost:8080');
|
|
83
|
-
expect(result).toBe(true);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('should allow 127.0.0.1 when APP_URL is localhost', () => {
|
|
87
|
-
process.env.APP_URL = 'http://localhost:3000';
|
|
88
|
-
const result = validateRedirectHost('127.0.0.1');
|
|
89
|
-
expect(result).toBe(true);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('should allow 127.0.0.1 with port when APP_URL is localhost', () => {
|
|
93
|
-
process.env.APP_URL = 'http://localhost:3000';
|
|
94
|
-
const result = validateRedirectHost('127.0.0.1:8080');
|
|
95
|
-
expect(result).toBe(true);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('should allow 0.0.0.0 when APP_URL is localhost', () => {
|
|
99
|
-
process.env.APP_URL = 'http://localhost:3000';
|
|
100
|
-
const result = validateRedirectHost('0.0.0.0');
|
|
101
|
-
expect(result).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('should allow 0.0.0.0 with port when APP_URL is localhost', () => {
|
|
105
|
-
process.env.APP_URL = 'http://localhost:3000';
|
|
106
|
-
const result = validateRedirectHost('0.0.0.0:8080');
|
|
107
|
-
expect(result).toBe(true);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('should allow localhost when APP_URL is 127.0.0.1', () => {
|
|
111
|
-
process.env.APP_URL = 'http://127.0.0.1:3000';
|
|
112
|
-
const result = validateRedirectHost('localhost');
|
|
113
|
-
expect(result).toBe(true);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('should allow localhost when APP_URL is 0.0.0.0', () => {
|
|
117
|
-
process.env.APP_URL = 'http://0.0.0.0:3000';
|
|
118
|
-
const result = validateRedirectHost('localhost');
|
|
119
|
-
expect(result).toBe(true);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('should reject localhost when APP_URL is not a local address', () => {
|
|
123
|
-
process.env.APP_URL = 'https://example.com';
|
|
124
|
-
const result = validateRedirectHost('localhost');
|
|
125
|
-
expect(result).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('should reject 127.0.0.1 when APP_URL is not a local address', () => {
|
|
129
|
-
process.env.APP_URL = 'https://example.com';
|
|
130
|
-
const result = validateRedirectHost('127.0.0.1');
|
|
131
|
-
expect(result).toBe(false);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('should reject 0.0.0.0 when APP_URL is not a local address', () => {
|
|
135
|
-
process.env.APP_URL = 'https://example.com';
|
|
136
|
-
const result = validateRedirectHost('0.0.0.0');
|
|
137
|
-
expect(result).toBe(false);
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe('subdomain validation', () => {
|
|
142
|
-
it('should allow valid subdomain of APP_URL domain', () => {
|
|
143
|
-
process.env.APP_URL = 'https://example.com';
|
|
144
|
-
const result = validateRedirectHost('api.example.com');
|
|
145
|
-
expect(result).toBe(true);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('should allow multi-level subdomain', () => {
|
|
149
|
-
process.env.APP_URL = 'https://example.com';
|
|
150
|
-
const result = validateRedirectHost('api.v1.example.com');
|
|
151
|
-
expect(result).toBe(true);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('should allow subdomain with port', () => {
|
|
155
|
-
process.env.APP_URL = 'https://example.com';
|
|
156
|
-
const result = validateRedirectHost('api.example.com:8080');
|
|
157
|
-
expect(result).toBe(true);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('should reject domain that is not a subdomain', () => {
|
|
161
|
-
process.env.APP_URL = 'https://example.com';
|
|
162
|
-
const result = validateRedirectHost('fakeexample.com');
|
|
163
|
-
expect(result).toBe(false);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('should reject domain that contains but is not subdomain', () => {
|
|
167
|
-
process.env.APP_URL = 'https://example.com';
|
|
168
|
-
const result = validateRedirectHost('notexample.com');
|
|
169
|
-
expect(result).toBe(false);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('should reject completely different domain', () => {
|
|
173
|
-
process.env.APP_URL = 'https://example.com';
|
|
174
|
-
const result = validateRedirectHost('evil.com');
|
|
175
|
-
expect(result).toBe(false);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it('should handle APP_URL with port when validating subdomains', () => {
|
|
179
|
-
process.env.APP_URL = 'https://example.com:8080';
|
|
180
|
-
const result = validateRedirectHost('api.example.com');
|
|
181
|
-
expect(result).toBe(true);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('should handle APP_URL with subdomain when validating further subdomains', () => {
|
|
185
|
-
process.env.APP_URL = 'https://api.example.com';
|
|
186
|
-
const result = validateRedirectHost('v1.api.example.com');
|
|
187
|
-
expect(result).toBe(true);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe('open redirect attack prevention', () => {
|
|
192
|
-
it('should block redirection to malicious external domain', () => {
|
|
193
|
-
process.env.APP_URL = 'https://example.com';
|
|
194
|
-
const result = validateRedirectHost('malicious.com');
|
|
195
|
-
expect(result).toBe(false);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('should block redirection to similar-looking domain', () => {
|
|
199
|
-
process.env.APP_URL = 'https://example.com';
|
|
200
|
-
const result = validateRedirectHost('example.com.evil.com');
|
|
201
|
-
expect(result).toBe(false);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('should block redirection to domain with extra TLD', () => {
|
|
205
|
-
process.env.APP_URL = 'https://example.com';
|
|
206
|
-
const result = validateRedirectHost('example.com.br');
|
|
207
|
-
expect(result).toBe(false);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('should block redirection using homograph attack attempt', () => {
|
|
211
|
-
process.env.APP_URL = 'https://example.com';
|
|
212
|
-
// Using similar-looking characters
|
|
213
|
-
const result = validateRedirectHost('examp1e.com');
|
|
214
|
-
expect(result).toBe(false);
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
describe('port handling', () => {
|
|
219
|
-
it('should handle standard HTTPS port (443) - normalized by URL API', () => {
|
|
220
|
-
// Note: URL API normalizes standard ports, so :443 is removed from https URLs
|
|
221
|
-
process.env.APP_URL = 'https://example.com:443';
|
|
222
|
-
// APP_URL becomes https://example.com (443 is default for https)
|
|
223
|
-
const result = validateRedirectHost('example.com');
|
|
224
|
-
expect(result).toBe(true);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('should handle standard HTTP port (80) - normalized by URL API', () => {
|
|
228
|
-
// Note: URL API normalizes standard ports, so :80 is removed from http URLs
|
|
229
|
-
process.env.APP_URL = 'http://example.com:80';
|
|
230
|
-
// APP_URL becomes http://example.com (80 is default for http)
|
|
231
|
-
const result = validateRedirectHost('example.com');
|
|
232
|
-
expect(result).toBe(true);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it('should handle custom ports', () => {
|
|
236
|
-
process.env.APP_URL = 'https://example.com:3000';
|
|
237
|
-
const result = validateRedirectHost('example.com:3000');
|
|
238
|
-
expect(result).toBe(true);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('should reject different ports on same domain', () => {
|
|
242
|
-
process.env.APP_URL = 'https://example.com:3000';
|
|
243
|
-
const result = validateRedirectHost('example.com:4000');
|
|
244
|
-
expect(result).toBe(false);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('should allow subdomain with different port than APP_URL', () => {
|
|
248
|
-
process.env.APP_URL = 'https://example.com:3000';
|
|
249
|
-
const result = validateRedirectHost('api.example.com:8080');
|
|
250
|
-
expect(result).toBe(true);
|
|
251
|
-
});
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
describe('edge cases', () => {
|
|
255
|
-
it('should handle APP_URL with trailing slash', () => {
|
|
256
|
-
process.env.APP_URL = 'https://example.com/';
|
|
257
|
-
const result = validateRedirectHost('example.com');
|
|
258
|
-
expect(result).toBe(true);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('should handle APP_URL with path', () => {
|
|
262
|
-
process.env.APP_URL = 'https://example.com/app';
|
|
263
|
-
const result = validateRedirectHost('example.com');
|
|
264
|
-
expect(result).toBe(true);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it('should handle uppercase in targetHost', () => {
|
|
268
|
-
process.env.APP_URL = 'https://example.com';
|
|
269
|
-
const result = validateRedirectHost('EXAMPLE.COM');
|
|
270
|
-
expect(result).toBe(false);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('should handle mixed case domains - URL API lowercases hostnames', () => {
|
|
274
|
-
// Note: URL API automatically lowercases hostnames
|
|
275
|
-
process.env.APP_URL = 'https://Example.Com';
|
|
276
|
-
// URL API converts it to example.com
|
|
277
|
-
const result = validateRedirectHost('example.com');
|
|
278
|
-
expect(result).toBe(true);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it('should handle IPv4 addresses in APP_URL', () => {
|
|
282
|
-
process.env.APP_URL = 'http://192.168.1.1:3000';
|
|
283
|
-
const result = validateRedirectHost('192.168.1.1:3000');
|
|
284
|
-
expect(result).toBe(true);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('should reject different IPv4 addresses', () => {
|
|
288
|
-
process.env.APP_URL = 'http://192.168.1.1:3000';
|
|
289
|
-
const result = validateRedirectHost('192.168.1.2:3000');
|
|
290
|
-
expect(result).toBe(false);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it('should handle empty APP_URL gracefully', () => {
|
|
294
|
-
process.env.APP_URL = '';
|
|
295
|
-
const result = validateRedirectHost('example.com');
|
|
296
|
-
expect(result).toBe(false);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it('should handle whitespace in targetHost', () => {
|
|
300
|
-
const result = validateRedirectHost(' example.com ');
|
|
301
|
-
expect(result).toBe(false);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('should handle single dot in targetHost', () => {
|
|
305
|
-
const result = validateRedirectHost('.');
|
|
306
|
-
expect(result).toBe(false);
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it('should handle double dots in targetHost', () => {
|
|
310
|
-
const result = validateRedirectHost('example..com');
|
|
311
|
-
expect(result).toBe(false);
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
describe('real-world scenarios', () => {
|
|
316
|
-
it('should validate production domain correctly', () => {
|
|
317
|
-
process.env.APP_URL = 'https://chat.lobehub.com';
|
|
318
|
-
const result = validateRedirectHost('chat.lobehub.com');
|
|
319
|
-
expect(result).toBe(true);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it('should allow API subdomain in production', () => {
|
|
323
|
-
process.env.APP_URL = 'https://chat.lobehub.com';
|
|
324
|
-
const result = validateRedirectHost('api.chat.lobehub.com');
|
|
325
|
-
expect(result).toBe(true);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('should block redirect to competitor domain', () => {
|
|
329
|
-
process.env.APP_URL = 'https://chat.lobehub.com';
|
|
330
|
-
const result = validateRedirectHost('competitor.com');
|
|
331
|
-
expect(result).toBe(false);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it('should support development environment with port', () => {
|
|
335
|
-
process.env.APP_URL = 'http://localhost:3010';
|
|
336
|
-
const result = validateRedirectHost('localhost:3010');
|
|
337
|
-
expect(result).toBe(true);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it('should support staging environment', () => {
|
|
341
|
-
process.env.APP_URL = 'https://staging.example.com';
|
|
342
|
-
const result = validateRedirectHost('staging.example.com');
|
|
343
|
-
expect(result).toBe(true);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
it('should allow preview deployment subdomain', () => {
|
|
347
|
-
process.env.APP_URL = 'https://example.com';
|
|
348
|
-
const result = validateRedirectHost('pr-123.example.com');
|
|
349
|
-
expect(result).toBe(true);
|
|
350
|
-
});
|
|
351
|
-
});
|
|
352
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
};
|