From 66f1f089c5c57122994ace7ff9f4b03b0f10f1fc Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 16 Jun 2026 09:26:55 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat:=20=E5=A2=9E=E5=BC=BAURL=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E5=9C=B0=E5=9D=80=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在app-instance/frontend/lib/api.ts中实现更严格的URL验证逻辑, 包括检查是否以斜杠开头、包含空格字符,以及使用URL构造函数进行验证 - 在app-instance/frontend/lib/auth-portal.ts中应用相同的URL验证改进, 提升认证门户的基础地址处理安全性 - 在auth-portal/src/lib/auth-client.ts中增强前端跳转URL构建功能, 添加错误处理机制并在URL构造失败时抛出相应异常 - 统一三个文件中的normalizeBaseUrl函数实现,确保一致的输入验证行为 ``` --- app-instance/frontend/lib/api.ts | 10 ++++- app-instance/frontend/lib/auth-portal.ts | 11 ++++- app-instance/frontend/lib/auth-url.test.ts | 51 ++++++++++++++++++++++ auth-portal/src/lib/auth-client.ts | 17 +++++++- 4 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 app-instance/frontend/lib/auth-url.test.ts diff --git a/app-instance/frontend/lib/api.ts b/app-instance/frontend/lib/api.ts index dc78b0d..f7165ef 100644 --- a/app-instance/frontend/lib/api.ts +++ b/app-instance/frontend/lib/api.ts @@ -79,7 +79,15 @@ function isBrowser(): boolean { function normalizeBaseUrl(value?: string | null): string | null { const trimmed = value?.trim(); if (!trimmed) return null; - return trimmed.replace(/\/+$/, ''); + if (trimmed.startsWith('/') || /\s/.test(trimmed)) return null; + const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed); + const candidate = hasScheme ? trimmed : `http://${trimmed}`; + try { + const url = new URL(candidate); + return url.toString().replace(/\/+$/, ''); + } catch { + return null; + } } export function buildAuthHandoffUrl(response: TokenResponse, nextPath: string): string | null { diff --git a/app-instance/frontend/lib/auth-portal.ts b/app-instance/frontend/lib/auth-portal.ts index c96b99b..39c5525 100644 --- a/app-instance/frontend/lib/auth-portal.ts +++ b/app-instance/frontend/lib/auth-portal.ts @@ -6,7 +6,15 @@ const AUTH_PORTAL_PORT = process.env.NEXT_PUBLIC_AUTH_PORTAL_PORT?.trim() || '30 function normalizeBaseUrl(value?: string | null): string | null { const trimmed = value?.trim(); if (!trimmed) return null; - return trimmed.replace(/\/+$/, ''); + if (trimmed.startsWith('/') || /\s/.test(trimmed)) return null; + const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed); + const candidate = hasScheme ? trimmed : `http://${trimmed}`; + try { + const url = new URL(candidate); + return url.toString().replace(/\/+$/, ''); + } catch { + return null; + } } function getPortalBaseUrl(): string { @@ -28,4 +36,3 @@ export function buildAuthPortalUrl(path: '/login' | '/register', nextPath?: stri } return url.toString(); } - diff --git a/app-instance/frontend/lib/auth-url.test.ts b/app-instance/frontend/lib/auth-url.test.ts new file mode 100644 index 0000000..16dd224 --- /dev/null +++ b/app-instance/frontend/lib/auth-url.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildAuthHandoffUrl } from './api'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe('auth URL handling', () => { + it('builds auth portal URLs when configured portal host has no scheme', async () => { + vi.stubEnv('NEXT_PUBLIC_AUTH_PORTAL_URL', 'auth.example.com'); + const { buildAuthPortalUrl } = await import('./auth-portal'); + + expect(buildAuthPortalUrl('/login', '/mcp')).toBe('http://auth.example.com/login?next=%2Fmcp'); + }); + + it('builds a handoff URL when backend returns a hostname without scheme', () => { + const url = buildAuthHandoffUrl({ + access_token: 'token', + refresh_token: '', + token_type: 'bearer', + user_id: 'u1', + username: 'u1', + role: 'owner', + handoff_code: 'handoff-1', + backend_connection: { + frontend_base_url: 'workspace.example.com:8088', + }, + }, '/mcp'); + + expect(url).toBe('http://workspace.example.com:8088/handoff?code=handoff-1&next=%2Fmcp'); + }); + + it('rejects malformed handoff base URLs instead of throwing URL constructor errors', () => { + const url = buildAuthHandoffUrl({ + access_token: 'token', + refresh_token: '', + token_type: 'bearer', + user_id: 'u1', + username: 'u1', + role: 'owner', + handoff_code: 'handoff-1', + backend_connection: { + frontend_base_url: 'http://', + }, + }, '/mcp'); + + expect(url).toBeNull(); + }); +}); diff --git a/auth-portal/src/lib/auth-client.ts b/auth-portal/src/lib/auth-client.ts index a278c99..bd7b277 100644 --- a/auth-portal/src/lib/auth-client.ts +++ b/auth-portal/src/lib/auth-client.ts @@ -19,7 +19,15 @@ export interface ProviderOnboardingPayload { function normalizeBaseUrl(value?: string | null): string | null { const trimmed = value?.trim(); if (!trimmed) return null; - return trimmed.replace(/\/+$/, ''); + if (trimmed.startsWith('/') || /\s/.test(trimmed)) return null; + const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed); + const candidate = hasScheme ? trimmed : `http://${trimmed}`; + try { + const url = new URL(candidate); + return url.toString().replace(/\/+$/, ''); + } catch { + return null; + } } function getFrontendBaseUrl(response: TokenResponse): string | null { @@ -110,7 +118,12 @@ export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: strin throw new Error(pickPortalText(locale, '后端未返回 handoff code', 'Backend did not return a handoff code')); } - const url = new URL('/handoff', frontendBaseUrl); + let url: URL; + try { + url = new URL('/handoff', frontendBaseUrl); + } catch { + throw new Error(pickPortalText(locale, '目标前端地址格式无效', 'Target frontend URL is invalid')); + } url.searchParams.set('code', handoffCode); if (nextPath) { url.searchParams.set('next', nextPath);