@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.
- package/.github/workflows/test.yml +32 -68
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +14 -0
- package/e2e/package.json +1 -1
- package/e2e/src/mocks/index.ts +2 -2
- package/e2e/src/steps/{discover → community}/detail-pages.steps.ts +8 -8
- package/e2e/src/steps/{discover → community}/interactions.steps.ts +4 -4
- package/package.json +1 -1
- package/packages/utils/src/server/correctOIDCUrl.test.ts +80 -19
- package/packages/utils/src/server/correctOIDCUrl.ts +89 -24
- package/packages/utils/src/server/index.ts +1 -0
- package/src/app/(backend)/oidc/callback/desktop/route.ts +7 -36
- package/src/app/[variants]/router/index.tsx +8 -12
- package/src/libs/next/proxy/define-config.ts +8 -4
- /package/e2e/src/features/{discover → community}/detail-pages.feature +0 -0
- /package/e2e/src/features/{discover → community}/interactions.feature +0 -0
- /package/e2e/src/features/{discover → community}/smoke.feature +0 -0
- /package/e2e/src/mocks/{discover → community}/data.ts +0 -0
- /package/e2e/src/mocks/{discover → community}/handlers.ts +0 -0
- /package/e2e/src/mocks/{discover → community}/index.ts +0 -0
- /package/e2e/src/mocks/{discover → community}/types.ts +0 -0
- /package/e2e/src/steps/{discover → community}/smoke.steps.ts +0 -0
|
@@ -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:
|
|
20
|
-
skip_after_successful_duplicate:
|
|
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 -
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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:
|
|
48
|
+
bun-version: ${{ secrets.BUN_VERSION }}
|
|
96
49
|
|
|
97
50
|
- name: Install deps
|
|
98
51
|
run: bun i
|
|
99
52
|
|
|
100
|
-
- name: Test
|
|
101
|
-
run:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
[](#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
|
+
[](#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:
|
|
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'",
|
package/e2e/src/mocks/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Page, Route } from 'playwright';
|
|
8
8
|
|
|
9
|
-
import { discoverMocks } from './
|
|
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
|
-
|
|
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 = /\/
|
|
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
|
-
!/\/
|
|
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 = /\/
|
|
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') && !/\/
|
|
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 = /\/
|
|
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') && !/\/
|
|
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 = /\/
|
|
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') && !/\/
|
|
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 = /\/
|
|
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 = /\/
|
|
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 = /\/
|
|
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 = /\/
|
|
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.
|
|
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
|
|
342
|
-
expect(result).toBe(
|
|
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 '
|
|
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
|
|
371
|
-
expect(result).toBe(
|
|
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
|
|
414
|
-
expect(result).toBe(
|
|
415
|
-
expect(result.
|
|
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 '
|
|
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
|
|
448
|
-
expect(result).toBe(
|
|
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 '
|
|
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
|
|
462
|
-
expect(result).toBe(
|
|
463
|
-
|
|
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
|
-
'
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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 !==
|
|
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
|
-
|
|
52
|
-
|
|
117
|
+
log(
|
|
118
|
+
'URL needs correction. Original hostname: %s, correcting to: %s',
|
|
119
|
+
url.hostname,
|
|
120
|
+
safeOriginUrl.hostname,
|
|
121
|
+
);
|
|
53
122
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
123
|
+
try {
|
|
124
|
+
const correctedUrl = new URL(url.toString());
|
|
125
|
+
correctedUrl.protocol = safeOriginUrl.protocol;
|
|
126
|
+
correctedUrl.host = safeOriginUrl.host;
|
|
58
127
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
};
|
|
@@ -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
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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(
|
|
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
|
|
3
|
+
import dynamic from 'next/dynamic';
|
|
4
4
|
|
|
5
|
-
import
|
|
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
|
-
|
|
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
|
|
238
|
-
|
|
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
|
|
324
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|