@lobehub/lobehub 2.0.0-next.222 → 2.0.0-next.224

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.
@@ -6,6 +6,10 @@ permissions:
6
6
  actions: write
7
7
  contents: read
8
8
 
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
9
13
  jobs:
10
14
  # Check for duplicate runs
11
15
  pre_job:
@@ -16,69 +20,18 @@ jobs:
16
20
  - id: skip_check
17
21
  uses: fkirc/skip-duplicate-actions@v5
18
22
  with:
19
- concurrent_skipping: 'same_content_newer'
20
- skip_after_successful_duplicate: 'true'
23
+ concurrent_skipping: "same_content_newer"
24
+ skip_after_successful_duplicate: "true"
21
25
  do_not_skip: '["workflow_dispatch", "schedule"]'
22
26
 
23
- # Package tests - using each package's own test script
24
- test-intenral-packages:
25
- needs: pre_job
26
- if: needs.pre_job.outputs.should_skip != 'true'
27
- runs-on: ubuntu-latest
28
- strategy:
29
- matrix:
30
- package:
31
- - file-loaders
32
- - prompts
33
- - model-runtime
34
- - web-crawler
35
- - electron-server-ipc
36
- - utils
37
- - python-interpreter
38
- - context-engine
39
- - agent-runtime
40
- - conversation-flow
41
- - ssrf-safe-fetch
42
- - memory-user-memory
43
-
44
- name: Test package ${{ matrix.package }}
45
-
46
- steps:
47
- - uses: actions/checkout@v6
48
-
49
- - name: Setup Node.js
50
- uses: actions/setup-node@v6
51
- with:
52
- node-version: 24.11.1
53
- package-manager-cache: false
54
-
55
- - name: Install bun
56
- uses: oven-sh/setup-bun@v2
57
- with:
58
- bun-version: ${{ secrets.BUN_VERSION }}
59
-
60
- - name: Install deps
61
- run: bun i
62
-
63
- - name: Test ${{ matrix.package }} package with coverage
64
- run: bun run --filter @lobechat/${{ matrix.package }} test:coverage
65
-
66
- - name: Upload ${{ matrix.package }} coverage to Codecov
67
- uses: codecov/codecov-action@v5
68
- with:
69
- token: ${{ secrets.CODECOV_TOKEN }}
70
- files: ./packages/${{ matrix.package }}/coverage/lcov.info
71
- flags: packages/${{ matrix.package }}
72
-
27
+ # Package tests - all packages in single job to save runner resources
73
28
  test-packages:
74
29
  needs: pre_job
75
30
  if: needs.pre_job.outputs.should_skip != 'true'
76
31
  runs-on: ubuntu-latest
77
- strategy:
78
- matrix:
79
- package: [model-bank]
80
-
81
- name: Test package ${{ matrix.package }}
32
+ name: Test Packages
33
+ env:
34
+ PACKAGES: "@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory model-bank"
82
35
 
83
36
  steps:
84
37
  - uses: actions/checkout@v6
@@ -92,20 +45,32 @@ jobs:
92
45
  - name: Install bun
93
46
  uses: oven-sh/setup-bun@v2
94
47
  with:
95
- bun-version: latest
48
+ bun-version: ${{ secrets.BUN_VERSION }}
96
49
 
97
50
  - name: Install deps
98
51
  run: bun i
99
52
 
100
- - name: Test ${{ matrix.package }} package with coverage
101
- run: bun run --filter ${{ matrix.package }} test:coverage
102
-
103
- - name: Upload ${{ matrix.package }} coverage to Codecov
104
- uses: codecov/codecov-action@v5
105
- with:
106
- token: ${{ secrets.CODECOV_TOKEN }}
107
- files: ./packages/${{ matrix.package }}/coverage/lcov.info
108
- flags: packages/${{ matrix.package }}
53
+ - name: Test packages with coverage
54
+ run: |
55
+ for package in $PACKAGES; do
56
+ echo "::group::Testing $package"
57
+ bun run --filter $package test:coverage
58
+ echo "::endgroup::"
59
+ done
60
+
61
+ - name: Upload coverage to Codecov
62
+ if: always()
63
+ run: |
64
+ for package in $PACKAGES; do
65
+ # Extract directory name: @lobechat/file-loaders -> file-loaders, model-bank -> model-bank
66
+ dir="${package#@lobechat/}"
67
+ if [ -f "./packages/$dir/coverage/lcov.info" ]; then
68
+ echo "Uploading coverage for $package..."
69
+ npx codecov --token=${{ secrets.CODECOV_TOKEN }} \
70
+ --file=./packages/$dir/coverage/lcov.info \
71
+ --flags=packages/$dir
72
+ fi
73
+ done
109
74
 
110
75
  # App tests
111
76
  test-website:
@@ -199,7 +164,6 @@ jobs:
199
164
  options: >-
200
165
  --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
201
166
 
202
-
203
167
  ports:
204
168
  - 5432:5432
205
169
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.224](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.223...v2.0.0-next.224)
6
+
7
+ <sup>Released on **2026-01-06**</sup>
8
+
9
+ #### ♻ Code Refactoring
10
+
11
+ - **router**: Replace client-side rendering with dynamic import for DesktopClientRouter.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Code refactoring
19
+
20
+ - **router**: Replace client-side rendering with dynamic import for DesktopClientRouter, closes [#11276](https://github.com/lobehub/lobe-chat/issues/11276) ([f50305b](https://github.com/lobehub/lobe-chat/commit/f50305b))
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 2.0.0-next.223](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.222...v2.0.0-next.223)
31
+
32
+ <sup>Released on **2026-01-06**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **misc**: Fix callback url error during signin period.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **misc**: Fix callback url error during signin period, closes [#11139](https://github.com/lobehub/lobe-chat/issues/11139) ([3fc69c5](https://github.com/lobehub/lobe-chat/commit/3fc69c5))
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 2.0.0-next.222](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.221...v2.0.0-next.222)
6
56
 
7
57
  <sup>Released on **2026-01-06**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2026-01-06",
5
+ "version": "2.0.0-next.224"
6
+ },
7
+ {
8
+ "children": {
9
+ "fixes": [
10
+ "Fix callback url error during signin period."
11
+ ]
12
+ },
13
+ "date": "2026-01-06",
14
+ "version": "2.0.0-next.223"
15
+ },
2
16
  {
3
17
  "children": {
4
18
  "fixes": [
package/e2e/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "build": "cd .. && bun run build",
8
8
  "test": "cucumber-js --config cucumber.config.js",
9
9
  "test:ci": "bun run build && bun run test",
10
- "test:discover": "cucumber-js --config cucumber.config.js src/features/discover/",
10
+ "test:community": "cucumber-js --config cucumber.config.js src/features/community/",
11
11
  "test:headed": "HEADLESS=false cucumber-js --config cucumber.config.js",
12
12
  "test:routes": "cucumber-js --config cucumber.config.js --tags '@routes'",
13
13
  "test:routes:ci": "cucumber-js --config cucumber.config.js --tags '@routes and not @ci-skip'",
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { Page, Route } from 'playwright';
8
8
 
9
- import { discoverMocks } from './discover';
9
+ import { discoverMocks } from './community';
10
10
 
11
11
  // ============================================
12
12
  // Types
@@ -35,7 +35,7 @@ export interface MockConfig {
35
35
  const defaultConfig: MockConfig = {
36
36
  enabled: true,
37
37
  handlers: {
38
- discover: discoverMocks,
38
+ community: discoverMocks,
39
39
  // Add more domains here as needed:
40
40
  // user: userMocks,
41
41
  // chat: chatMocks,
@@ -47,7 +47,7 @@ Then('I should be on an assistant detail page', async function (this: CustomWorl
47
47
 
48
48
  const currentUrl = this.page.url();
49
49
  // Check if URL matches assistant detail page pattern
50
- const hasAssistantDetail = /\/discover\/assistant\/[^#?]+/.test(currentUrl);
50
+ const hasAssistantDetail = /\/community\/assistant\/[^#?]+/.test(currentUrl);
51
51
  expect(
52
52
  hasAssistantDetail,
53
53
  `Expected URL to match assistant detail page pattern, but got: ${currentUrl}`,
@@ -116,7 +116,7 @@ Then('I should be on the assistant list page', async function (this: CustomWorld
116
116
  // Check if URL is assistant list (not detail page)
117
117
  const isListPage =
118
118
  currentUrl.includes('/community/assistant') &&
119
- !/\/discover\/assistant\/[^#?]+/.test(currentUrl);
119
+ !/\/community\/assistant\/[^#?]+/.test(currentUrl);
120
120
  expect(isListPage, `Expected URL to be assistant list page, but got: ${currentUrl}`).toBeTruthy();
121
121
  });
122
122
 
@@ -126,7 +126,7 @@ Then('I should be on a model detail page', async function (this: CustomWorld) {
126
126
 
127
127
  const currentUrl = this.page.url();
128
128
  // Check if URL matches model detail page pattern
129
- const hasModelDetail = /\/discover\/model\/[^#?]+/.test(currentUrl);
129
+ const hasModelDetail = /\/community\/model\/[^#?]+/.test(currentUrl);
130
130
  expect(
131
131
  hasModelDetail,
132
132
  `Expected URL to match model detail page pattern, but got: ${currentUrl}`,
@@ -175,7 +175,7 @@ Then('I should be on the model list page', async function (this: CustomWorld) {
175
175
  const currentUrl = this.page.url();
176
176
  // Check if URL is model list (not detail page)
177
177
  const isListPage =
178
- currentUrl.includes('/community/model') && !/\/discover\/model\/[^#?]+/.test(currentUrl);
178
+ currentUrl.includes('/community/model') && !/\/community\/model\/[^#?]+/.test(currentUrl);
179
179
  expect(isListPage, `Expected URL to be model list page, but got: ${currentUrl}`).toBeTruthy();
180
180
  });
181
181
 
@@ -185,7 +185,7 @@ Then('I should be on a provider detail page', async function (this: CustomWorld)
185
185
 
186
186
  const currentUrl = this.page.url();
187
187
  // Check if URL matches provider detail page pattern
188
- const hasProviderDetail = /\/discover\/provider\/[^#?]+/.test(currentUrl);
188
+ const hasProviderDetail = /\/community\/provider\/[^#?]+/.test(currentUrl);
189
189
  expect(
190
190
  hasProviderDetail,
191
191
  `Expected URL to match provider detail page pattern, but got: ${currentUrl}`,
@@ -234,7 +234,7 @@ Then('I should be on the provider list page', async function (this: CustomWorld)
234
234
  const currentUrl = this.page.url();
235
235
  // Check if URL is provider list (not detail page)
236
236
  const isListPage =
237
- currentUrl.includes('/community/provider') && !/\/discover\/provider\/[^#?]+/.test(currentUrl);
237
+ currentUrl.includes('/community/provider') && !/\/community\/provider\/[^#?]+/.test(currentUrl);
238
238
  expect(isListPage, `Expected URL to be provider list page, but got: ${currentUrl}`).toBeTruthy();
239
239
  });
240
240
 
@@ -244,7 +244,7 @@ Then('I should be on an MCP detail page', async function (this: CustomWorld) {
244
244
 
245
245
  const currentUrl = this.page.url();
246
246
  // Check if URL matches MCP detail page pattern
247
- const hasMcpDetail = /\/discover\/mcp\/[^#?]+/.test(currentUrl);
247
+ const hasMcpDetail = /\/community\/mcp\/[^#?]+/.test(currentUrl);
248
248
  expect(
249
249
  hasMcpDetail,
250
250
  `Expected URL to match MCP detail page pattern, but got: ${currentUrl}`,
@@ -291,6 +291,6 @@ Then('I should be on the MCP list page', async function (this: CustomWorld) {
291
291
  const currentUrl = this.page.url();
292
292
  // Check if URL is MCP list (not detail page)
293
293
  const isListPage =
294
- currentUrl.includes('/community/mcp') && !/\/discover\/mcp\/[^#?]+/.test(currentUrl);
294
+ currentUrl.includes('/community/mcp') && !/\/community\/mcp\/[^#?]+/.test(currentUrl);
295
295
  expect(isListPage, `Expected URL to be MCP list page, but got: ${currentUrl}`).toBeTruthy();
296
296
  });
@@ -327,7 +327,7 @@ Then('I should be navigated to the assistant detail page', async function (this:
327
327
 
328
328
  const currentUrl = this.page.url();
329
329
  // Verify that URL changed and contains /assistant/ followed by an identifier
330
- const hasAssistantDetail = /\/discover\/assistant\/[^#?]+/.test(currentUrl);
330
+ const hasAssistantDetail = /\/community\/assistant\/[^#?]+/.test(currentUrl);
331
331
  const urlChanged = currentUrl !== this.testContext.previousUrl;
332
332
 
333
333
  expect(
@@ -362,7 +362,7 @@ Then('I should be navigated to the model detail page', async function (this: Cus
362
362
 
363
363
  const currentUrl = this.page.url();
364
364
  // Verify that URL changed and contains /model/ followed by an identifier
365
- const hasModelDetail = /\/discover\/model\/[^#?]+/.test(currentUrl);
365
+ const hasModelDetail = /\/community\/model\/[^#?]+/.test(currentUrl);
366
366
  const urlChanged = currentUrl !== this.testContext.previousUrl;
367
367
 
368
368
  expect(
@@ -384,7 +384,7 @@ Then('I should be navigated to the provider detail page', async function (this:
384
384
 
385
385
  const currentUrl = this.page.url();
386
386
  // Verify that URL changed and contains /provider/ followed by an identifier
387
- const hasProviderDetail = /\/discover\/provider\/[^#?]+/.test(currentUrl);
387
+ const hasProviderDetail = /\/community\/provider\/[^#?]+/.test(currentUrl);
388
388
  const urlChanged = currentUrl !== this.testContext.previousUrl;
389
389
 
390
390
  expect(
@@ -422,7 +422,7 @@ Then('I should be navigated to the MCP detail page', async function (this: Custo
422
422
 
423
423
  const currentUrl = this.page.url();
424
424
  // Verify that URL changed and contains /mcp/ followed by an identifier
425
- const hasMcpDetail = /\/discover\/mcp\/[^#?]+/.test(currentUrl);
425
+ const hasMcpDetail = /\/community\/mcp\/[^#?]+/.test(currentUrl);
426
426
  const urlChanged = currentUrl !== this.testContext.previousUrl;
427
427
 
428
428
  expect(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.222",
3
+ "version": "2.0.0-next.224",
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",
@@ -338,9 +338,8 @@ describe('correctOIDCUrl', () => {
338
338
  const originalUrl = new URL('http://localhost:3000/auth/callback');
339
339
  const result = correctOIDCUrl(mockRequest, originalUrl);
340
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');
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');
344
343
  });
345
344
 
346
345
  it('should not need correction when URL hostname matches actual host', () => {
@@ -358,18 +357,18 @@ describe('correctOIDCUrl', () => {
358
357
  });
359
358
 
360
359
  describe('Open Redirect protection', () => {
361
- it('should prevent redirection to malicious external domains', () => {
360
+ it('should prevent redirection to malicious external domains via x-forwarded-host', () => {
362
361
  (mockRequest.headers.get as any).mockImplementation((header: string) => {
363
- if (header === 'host') return 'malicious.com';
362
+ if (header === 'host') return 'example.com';
363
+ if (header === 'x-forwarded-host') return 'malicious.com';
364
364
  return null;
365
365
  });
366
366
 
367
367
  const originalUrl = new URL('http://localhost:3000/auth/callback');
368
368
  const result = correctOIDCUrl(mockRequest, originalUrl);
369
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');
370
+ // Should fall back to host header and not redirect to malicious.com
371
+ expect(result.toString()).toBe('http://example.com:3000/auth/callback');
373
372
  });
374
373
 
375
374
  it('should allow redirection to configured domain (example.com)', () => {
@@ -410,9 +409,9 @@ describe('correctOIDCUrl', () => {
410
409
  const originalUrl = new URL('http://localhost:3000/auth/callback');
411
410
  const result = correctOIDCUrl(mockRequest, originalUrl);
412
411
 
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');
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');
416
415
  });
417
416
 
418
417
  it('should allow localhost in development environment', () => {
@@ -437,30 +436,92 @@ describe('correctOIDCUrl', () => {
437
436
  delete process.env.APP_URL;
438
437
 
439
438
  (mockRequest.headers.get as any).mockImplementation((header: string) => {
440
- if (header === 'host') return 'any-domain.com';
439
+ if (header === 'host') return 'example.com';
440
+ if (header === 'x-forwarded-host') return 'any-domain.com';
441
441
  return null;
442
442
  });
443
443
 
444
444
  const originalUrl = new URL('http://localhost:3000/auth/callback');
445
445
  const result = correctOIDCUrl(mockRequest, originalUrl);
446
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');
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');
450
449
  });
451
450
 
452
451
  it('should handle domains that look like subdomains but are not', () => {
453
452
  (mockRequest.headers.get as any).mockImplementation((header: string) => {
454
- if (header === 'host') return 'fakeexample.com'; // Not a subdomain of example.com
453
+ if (header === 'host') return 'example.com';
454
+ if (header === 'x-forwarded-host') return 'fakeexample.com'; // Not a subdomain of example.com
455
455
  return null;
456
456
  });
457
457
 
458
458
  const originalUrl = new URL('http://localhost:3000/auth/callback');
459
459
  const result = correctOIDCUrl(mockRequest, originalUrl);
460
460
 
461
- // Should prevent redirection to fake domain
462
- expect(result).toBe(originalUrl);
463
- expect(result.toString()).toBe('http://localhost:3000/auth/callback');
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');
464
525
  });
465
526
  });
466
527
  });
@@ -5,29 +5,69 @@ import { validateRedirectHost } from './validateRedirectHost';
5
5
 
6
6
  const log = debug('lobe-oidc:correctOIDCUrl');
7
7
 
8
+ // Allowed protocols for security
9
+ const ALLOWED_PROTOCOLS = ['http', 'https'] as const;
10
+
8
11
  /**
9
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
+ *
10
24
  * @param req - Next.js request object
11
25
  * @param url - URL object to fix
12
26
  * @returns Fixed URL object
13
27
  */
14
28
  export const correctOIDCUrl = (req: NextRequest, url: URL): URL => {
29
+ log('Input URL: %s', url.toString());
30
+
31
+ // Get request headers for origin determination
15
32
  const requestHost = req.headers.get('host');
16
33
  const forwardedHost = req.headers.get('x-forwarded-host');
17
34
  const forwardedProto =
18
35
  req.headers.get('x-forwarded-proto') || req.headers.get('x-forwarded-protocol');
19
36
 
20
- log('Input URL: %s', url.toString());
21
37
  log(
22
- 'Request headers - host: %s, x-forwarded-host: %s, x-forwarded-proto: %s',
38
+ 'Getting safe origin - requestHost: %s, forwardedHost: %s, forwardedProto: %s',
23
39
  requestHost,
24
40
  forwardedHost,
25
41
  forwardedProto,
26
42
  );
27
43
 
28
- // Determine actual hostname and protocol with fallback values
29
- const actualHost = forwardedHost || requestHost;
30
- const actualProto = forwardedProto || (url.protocol === 'https:' ? 'https' : 'http');
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
+ }
31
71
 
32
72
  // If unable to determine valid hostname, return original URL
33
73
  if (!actualHost || actualHost === 'null') {
@@ -35,9 +75,30 @@ export const correctOIDCUrl = (req: NextRequest, url: URL): URL => {
35
75
  return url;
36
76
  }
37
77
 
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);
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);
41
102
  return url;
42
103
  }
43
104
 
@@ -46,24 +107,28 @@ export const correctOIDCUrl = (req: NextRequest, url: URL): URL => {
46
107
  url.hostname === 'localhost' ||
47
108
  url.hostname === '127.0.0.1' ||
48
109
  url.hostname === '0.0.0.0' ||
49
- url.hostname !== actualHost;
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
+ }
50
116
 
51
- if (needsCorrection) {
52
- log('URL needs correction. Original hostname: %s, correcting to: %s', url.hostname, actualHost);
117
+ log(
118
+ 'URL needs correction. Original hostname: %s, correcting to: %s',
119
+ url.hostname,
120
+ safeOriginUrl.hostname,
121
+ );
53
122
 
54
- try {
55
- const correctedUrl = new URL(url.toString());
56
- correctedUrl.protocol = actualProto + ':';
57
- correctedUrl.host = actualHost;
123
+ try {
124
+ const correctedUrl = new URL(url.toString());
125
+ correctedUrl.protocol = safeOriginUrl.protocol;
126
+ correctedUrl.host = safeOriginUrl.host;
58
127
 
59
- log('Corrected URL: %s', correctedUrl.toString());
60
- return correctedUrl;
61
- } catch (error) {
62
- log('Error creating corrected URL, returning original: %O', error);
63
- return url;
64
- }
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;
65
133
  }
66
-
67
- log('URL does not need correction, returning original: %s', url.toString());
68
- return url;
69
134
  };
@@ -3,4 +3,5 @@ export * from './correctOIDCUrl';
3
3
  export * from './response';
4
4
  export * from './responsive';
5
5
  export * from './sse';
6
+ export * from './validateRedirectHost';
6
7
  export * from './xor';
@@ -10,41 +10,15 @@ const log = debug('lobe-oidc:callback:desktop');
10
10
  const errorPathname = '/oauth/callback/error';
11
11
 
12
12
  /**
13
- * 安全地构建重定向URL
13
+ * 安全地构建重定向URL,使用经过验证的 correctOIDCUrl 防止开放重定向攻击
14
14
  */
15
15
  const buildRedirectUrl = (req: NextRequest, pathname: string): URL => {
16
- const forwardedHost = req.headers.get('x-forwarded-host');
17
- const requestHost = req.headers.get('host');
18
- const forwardedProto =
19
- req.headers.get('x-forwarded-proto') || req.headers.get('x-forwarded-protocol');
20
-
21
- // 确定实际的主机名,提供后备值
22
- const actualHost = forwardedHost || requestHost;
23
- const actualProto = forwardedProto || 'https';
24
-
25
- log(
26
- 'Building redirect URL - host: %s, proto: %s, pathname: %s',
27
- actualHost,
28
- actualProto,
29
- pathname,
30
- );
31
-
32
- // 如果主机名仍然无效,使用req.nextUrl作为后备
33
- if (!actualHost) {
34
- log('Warning: Invalid host detected, using req.nextUrl as fallback');
35
- const fallbackUrl = req.nextUrl.clone();
36
- fallbackUrl.pathname = pathname;
37
- return fallbackUrl;
38
- }
16
+ // 使用 req.nextUrl 作为基础URL,然后通过 correctOIDCUrl 进行验证和修正
17
+ const baseUrl = req.nextUrl.clone();
18
+ baseUrl.pathname = pathname;
39
19
 
40
- try {
41
- return new URL(`${actualProto}://${actualHost}${pathname}`);
42
- } catch (error) {
43
- log('Error constructing URL, using req.nextUrl as fallback: %O', error);
44
- const fallbackUrl = req.nextUrl.clone();
45
- fallbackUrl.pathname = pathname;
46
- return fallbackUrl;
47
- }
20
+ // correctOIDCUrl 会验证 X-Forwarded-* 头部并防止开放重定向攻击
21
+ return correctOIDCUrl(req, baseUrl);
48
22
  };
49
23
 
50
24
  export const GET = async (req: NextRequest) => {
@@ -82,9 +56,6 @@ export const GET = async (req: NextRequest) => {
82
56
  log('Request x-forwarded-proto: %s', req.headers.get('x-forwarded-proto'));
83
57
  log('Constructed success URL: %s', successUrl.toString());
84
58
 
85
- const correctedUrl = correctOIDCUrl(req, successUrl);
86
- log('Final redirect URL: %s', correctedUrl.toString());
87
-
88
59
  // cleanup expired
89
60
  after(async () => {
90
61
  const cleanedCount = await authHandoffModel.cleanupExpired();
@@ -92,7 +63,7 @@ export const GET = async (req: NextRequest) => {
92
63
  log('Cleaned up %d expired handoff records', cleanedCount);
93
64
  });
94
65
 
95
- return NextResponse.redirect(correctedUrl);
66
+ return NextResponse.redirect(successUrl);
96
67
  } catch (error) {
97
68
  log('Error in OIDC callback: %O', error);
98
69
 
@@ -1,20 +1,16 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState } from 'react';
3
+ import dynamic from 'next/dynamic';
4
4
 
5
- import DesktopClientRouter from './DesktopClientRouter';
5
+ import Loading from '@/components/Loading/BrandTextLoading';
6
+
7
+ const DesktopRouterClient = dynamic(() => import('./DesktopClientRouter'), {
8
+ loading: () => <Loading debugId="DesktopRouter" />,
9
+ ssr: false,
10
+ });
6
11
 
7
- const useIsClient = () => {
8
- const [isClient, setIsClient] = useState(false);
9
- useEffect(() => {
10
- setIsClient(true);
11
- }, []);
12
- return isClient;
13
- };
14
12
  const DesktopRouter = () => {
15
- const isClient = useIsClient();
16
- if (!isClient) return null;
17
- return <DesktopClientRouter />;
13
+ return <DesktopRouterClient />;
18
14
  };
19
15
 
20
16
  export default DesktopRouter;
@@ -234,8 +234,10 @@ export function defineConfig() {
234
234
  // ref: https://authjs.dev/getting-started/session-management/protecting
235
235
  if (isProtected) {
236
236
  logNextAuth('Request a protected route, redirecting to sign-in page');
237
- const nextLoginUrl = new URL('/next-auth/signin', req.nextUrl.origin);
238
- nextLoginUrl.searchParams.set('callbackUrl', req.nextUrl.href);
237
+ const authUrl = authEnv.NEXT_PUBLIC_AUTH_URL;
238
+ const callbackUrl = `${authUrl}${req.nextUrl.pathname}${req.nextUrl.search}`;
239
+ const nextLoginUrl = new URL('/next-auth/signin', authUrl);
240
+ nextLoginUrl.searchParams.set('callbackUrl', callbackUrl);
239
241
  const hl = req.nextUrl.searchParams.get('hl');
240
242
  if (hl) {
241
243
  nextLoginUrl.searchParams.set('hl', hl);
@@ -320,8 +322,10 @@ export function defineConfig() {
320
322
  // If request a protected route, redirect to sign-in page
321
323
  if (isProtected) {
322
324
  logBetterAuth('Request a protected route, redirecting to sign-in page');
323
- const signInUrl = new URL('/signin', req.nextUrl.origin);
324
- signInUrl.searchParams.set('callbackUrl', req.nextUrl.href);
325
+ const authUrl = authEnv.NEXT_PUBLIC_AUTH_URL;
326
+ const callbackUrl = `${authUrl}${req.nextUrl.pathname}${req.nextUrl.search}`;
327
+ const signInUrl = new URL('/signin', authUrl);
328
+ signInUrl.searchParams.set('callbackUrl', callbackUrl);
325
329
  const hl = req.nextUrl.searchParams.get('hl');
326
330
  if (hl) {
327
331
  signInUrl.searchParams.set('hl', hl);
File without changes
File without changes
File without changes