From b346e9430ae7ed9c27b8ede8b5d0059d7edc70f9 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 6 Feb 2026 18:06:21 +0800 Subject: [PATCH] fix: read sandbox binding from backend + harden proxies --- src/app/api/admin/sandbox/bind/route.ts | 37 ++++--- src/app/api/admin/sandbox/binding/route.ts | 41 +++++--- src/app/api/sandbox/assume/revert/route.ts | 82 ++++++++++++++++ src/app/api/sandbox/assume/route.ts | 96 +++++++++++++++++++ src/app/api/sandbox/assume/status/route.ts | 11 +++ src/app/api/sandbox/binding/route.ts | 49 ++++++++++ .../user-center/components/UserOverview.tsx | 15 ++- .../user-center/lib/sandboxNodeBinding.ts | 67 ++++--------- .../components/SandboxNodeBindingPanel.tsx | 42 +++----- .../builtin/user-center/routes/agent.tsx | 43 +++++---- 10 files changed, 364 insertions(+), 119 deletions(-) create mode 100644 src/app/api/sandbox/assume/revert/route.ts create mode 100644 src/app/api/sandbox/assume/route.ts create mode 100644 src/app/api/sandbox/assume/status/route.ts create mode 100644 src/app/api/sandbox/binding/route.ts diff --git a/src/app/api/admin/sandbox/bind/route.ts b/src/app/api/admin/sandbox/bind/route.ts index cd303dc..377979f 100644 --- a/src/app/api/admin/sandbox/bind/route.ts +++ b/src/app/api/admin/sandbox/bind/route.ts @@ -42,18 +42,31 @@ export async function POST(request: NextRequest) { const contentType = request.headers.get('content-type') ?? 'application/json' headers.set('Content-Type', contentType) - const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, { - method: 'POST', - headers, - body, - cache: 'no-store', - }) + try { + const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, { + method: 'POST', + headers, + body, + cache: 'no-store', + }) - const payload = await response.json().catch(() => null) - if (payload === null) { - return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.toLowerCase().includes('application/json')) { + const text = await response.text().catch(() => '') + return NextResponse.json( + { error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any, + { status: 502 }, + ) + } + + const payload = await response.json().catch(() => null) + if (payload === null) { + return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + } + + return NextResponse.json(payload, { status: response.status }) + } catch (error) { + console.error('Failed to proxy sandbox bind', error) + return NextResponse.json({ error: 'upstream_unreachable' }, { status: 502 }) } - - return NextResponse.json(payload, { status: response.status }) } - diff --git a/src/app/api/admin/sandbox/binding/route.ts b/src/app/api/admin/sandbox/binding/route.ts index 189333f..3ab4e5e 100644 --- a/src/app/api/admin/sandbox/binding/route.ts +++ b/src/app/api/admin/sandbox/binding/route.ts @@ -33,20 +33,33 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'root_only' }, { status: 403 }) } - const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, { - method: 'GET', - headers: { - Authorization: `Bearer ${session.token}`, - Accept: 'application/json', - }, - cache: 'no-store', - }) + try { + const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, { + method: 'GET', + headers: { + Authorization: `Bearer ${session.token}`, + Accept: 'application/json', + }, + cache: 'no-store', + }) - const payload = await response.json().catch(() => null) - if (payload === null) { - return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.toLowerCase().includes('application/json')) { + const text = await response.text().catch(() => '') + return NextResponse.json( + { error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any, + { status: 502 }, + ) + } + + const payload = await response.json().catch(() => null) + if (payload === null) { + return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + } + + return NextResponse.json(payload, { status: response.status }) + } catch (error) { + console.error('Failed to proxy sandbox binding', error) + return NextResponse.json({ error: 'upstream_unreachable' }, { status: 502 }) } - - return NextResponse.json(payload, { status: response.status }) } - diff --git a/src/app/api/sandbox/assume/revert/route.ts b/src/app/api/sandbox/assume/revert/route.ts new file mode 100644 index 0000000..4f1cec0 --- /dev/null +++ b/src/app/api/sandbox/assume/revert/route.ts @@ -0,0 +1,82 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' + +import { applySessionCookie } from '@lib/authGateway' +import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() + +const ROOT_BACKUP_COOKIE = 'xc_session_root' + +type ErrorPayload = { + error: string +} + +function secureCookies(): boolean { + if (process.env.NODE_ENV === 'production') { + return true + } + const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || '' + return baseUrl.toLowerCase().startsWith('https://') +} + +async function verifyRootToken(token: string): Promise { + try { + const res = await fetch(`${ACCOUNT_API_BASE}/session`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + cache: 'no-store', + }) + if (!res.ok) { + return false + } + const payload = (await res.json().catch(() => null)) as any + const email = typeof payload?.user?.email === 'string' ? payload.user.email.trim().toLowerCase() : '' + return email === 'admin@svc.plus' + } catch { + return false + } +} + +export async function POST(request: NextRequest) { + const rootToken = request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? '' + if (!rootToken) { + return NextResponse.json({ error: 'not_assuming' }, { status: 400 }) + } + + if (!(await verifyRootToken(rootToken))) { + return NextResponse.json({ error: 'root_token_invalid' }, { status: 403 }) + } + + // Best-effort audit log on accounts.svc.plus. (Cookies are owned by console.) + try { + await fetch(`${ACCOUNT_API_BASE}/admin/assume/revert`, { + method: 'POST', + headers: { + Authorization: `Bearer ${rootToken}`, + Accept: 'application/json', + }, + cache: 'no-store', + }) + } catch (error) { + console.error('Failed to audit assume revert', error) + } + + const response = NextResponse.json({ ok: true }) + applySessionCookie(response, rootToken) + response.cookies.set({ + name: ROOT_BACKUP_COOKIE, + value: '', + httpOnly: true, + secure: secureCookies(), + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return response +} + diff --git a/src/app/api/sandbox/assume/route.ts b/src/app/api/sandbox/assume/route.ts new file mode 100644 index 0000000..dff216c --- /dev/null +++ b/src/app/api/sandbox/assume/route.ts @@ -0,0 +1,96 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' + +import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway' +import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' +import { getAccountSession, userHasRole } from '@server/account/session' +import type { AccountUserRole } from '@server/account/session' + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() +const REQUIRED_ROLES: AccountUserRole[] = ['admin'] + +const ROOT_BACKUP_COOKIE = 'xc_session_root' +const SANDBOX_EMAIL = 'sandbox@svc.plus' + +type ErrorPayload = { + error: string +} + +function isAllowedRootEmail(email?: string): boolean { + return email?.trim().toLowerCase() === 'admin@svc.plus' +} + +function secureCookies(): boolean { + if (process.env.NODE_ENV === 'production') { + return true + } + const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || '' + return baseUrl.toLowerCase().startsWith('https://') +} + +export async function POST(request: NextRequest) { + const session = await getAccountSession(request) + const user = session.user + + if (!user || !session.token) { + return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) + } + + if (!(await userHasRole(user, REQUIRED_ROLES))) { + return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + } + + if (!isAllowedRootEmail(user.email)) { + return NextResponse.json({ error: 'root_only' }, { status: 403 }) + } + + try { + const upstream = await fetch(`${ACCOUNT_API_BASE}/admin/assume`, { + method: 'POST', + headers: { + Authorization: `Bearer ${session.token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email: SANDBOX_EMAIL }), + cache: 'no-store', + }) + + const contentType = upstream.headers.get('content-type') ?? '' + if (!contentType.toLowerCase().includes('application/json')) { + const text = await upstream.text().catch(() => '') + return NextResponse.json( + { error: 'upstream_non_json', upstreamStatus: upstream.status, upstreamBody: text.slice(0, 2048) } as any, + { status: 502 }, + ) + } + + const payload = (await upstream.json().catch(() => null)) as any + if (!payload || typeof payload.token !== 'string') { + return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + } + + const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL }) + + // Backup current root session token as a host-only cookie (do NOT set domain). + response.cookies.set({ + name: ROOT_BACKUP_COOKIE, + value: session.token, + httpOnly: true, + secure: secureCookies(), + sameSite: 'lax', + path: '/', + maxAge: deriveMaxAgeFromExpires(payload.expiresAt), + }) + + // Switch main session to sandbox token. + applySessionCookie(response, payload.token, deriveMaxAgeFromExpires(payload.expiresAt)) + + return response + } catch (error) { + console.error('Failed to assume sandbox', error) + return NextResponse.json({ error: 'upstream_unreachable' }, { status: 502 }) + } +} + diff --git a/src/app/api/sandbox/assume/status/route.ts b/src/app/api/sandbox/assume/status/route.ts new file mode 100644 index 0000000..2c11fd5 --- /dev/null +++ b/src/app/api/sandbox/assume/status/route.ts @@ -0,0 +1,11 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' + +const ROOT_BACKUP_COOKIE = 'xc_session_root' + +export async function GET(request: NextRequest) { + const isAssuming = Boolean(request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim()) + return NextResponse.json({ isAssuming, target: isAssuming ? 'sandbox@svc.plus' : '' }) +} + diff --git a/src/app/api/sandbox/binding/route.ts b/src/app/api/sandbox/binding/route.ts new file mode 100644 index 0000000..b44da6b --- /dev/null +++ b/src/app/api/sandbox/binding/route.ts @@ -0,0 +1,49 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' + +import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' +import { getAccountSession } from '@server/account/session' + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() + +type ErrorPayload = { + error: string +} + +export async function GET(request: NextRequest) { + const session = await getAccountSession(request) + if (!session.token) { + return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) + } + + try { + const response = await fetch(`${ACCOUNT_API_BASE}/sandbox/binding`, { + method: 'GET', + headers: { + Authorization: `Bearer ${session.token}`, + Accept: 'application/json', + }, + cache: 'no-store', + }) + + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.toLowerCase().includes('application/json')) { + const text = await response.text().catch(() => '') + return NextResponse.json( + { error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any, + { status: 502 }, + ) + } + + const payload = await response.json().catch(() => null) + if (payload === null) { + return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + } + return NextResponse.json(payload, { status: response.status }) + } catch (error) { + console.error('Failed to proxy sandbox binding (public)', error) + return NextResponse.json({ error: 'upstream_unreachable' }, { status: 502 }) + } +} + diff --git a/src/modules/extensions/builtin/user-center/components/UserOverview.tsx b/src/modules/extensions/builtin/user-center/components/UserOverview.tsx index e385446..43fc60d 100644 --- a/src/modules/extensions/builtin/user-center/components/UserOverview.tsx +++ b/src/modules/extensions/builtin/user-center/components/UserOverview.tsx @@ -8,7 +8,7 @@ import { Copy } from 'lucide-react' import { useLanguage } from '@i18n/LanguageProvider' import { translations } from '@i18n/translations' import { useUserStore } from '@lib/userStore' -import { getSandboxNodeBinding } from '../lib/sandboxNodeBinding' +import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding' import Card from './Card' import VlessQrCard from './VlessQrCard' @@ -127,8 +127,17 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview setSandboxBoundNodeAddress(null) return } - const binding = getSandboxNodeBinding() - setSandboxBoundNodeAddress(binding?.address ?? null) + let cancelled = false + void (async () => { + const binding = await fetchSandboxNodeBinding() + if (cancelled) { + return + } + setSandboxBoundNodeAddress(binding?.address ?? null) + })() + return () => { + cancelled = true + } }, [isGuestSandboxReadOnly]) useEffect(() => { diff --git a/src/modules/extensions/builtin/user-center/lib/sandboxNodeBinding.ts b/src/modules/extensions/builtin/user-center/lib/sandboxNodeBinding.ts index b8a272b..b91f505 100644 --- a/src/modules/extensions/builtin/user-center/lib/sandboxNodeBinding.ts +++ b/src/modules/extensions/builtin/user-center/lib/sandboxNodeBinding.ts @@ -1,7 +1,5 @@ 'use client' -const SANDBOX_NODE_BINDING_KEY = 'xcontrol.sandbox.node.binding.v1' - export type SandboxNodeBinding = { address: string name?: string @@ -9,52 +7,29 @@ export type SandboxNodeBinding = { updatedBy?: string } -export function getSandboxNodeBinding(): SandboxNodeBinding | null { - if (typeof window === 'undefined') { - return null - } - - const raw = window.localStorage.getItem(SANDBOX_NODE_BINDING_KEY) - if (!raw) { - return null - } - +export async function fetchSandboxNodeBinding(): Promise { try { - const parsed = JSON.parse(raw) as SandboxNodeBinding - if (typeof parsed?.address === 'string' && parsed.address.trim().length > 0) { - return { - address: parsed.address.trim(), - name: typeof parsed.name === 'string' && parsed.name.trim().length > 0 ? parsed.name.trim() : undefined, - updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(), - updatedBy: typeof parsed.updatedBy === 'string' && parsed.updatedBy.trim().length > 0 ? parsed.updatedBy.trim() : undefined, - } + const response = await fetch('/api/sandbox/binding', { method: 'GET', cache: 'no-store' }) + if (!response.ok) { + return null + } + const payload = (await response.json().catch(() => null)) as any + if (!payload || typeof payload.address !== 'string') { + return null + } + const address = payload.address.trim() + if (!address) { + return null + } + return { + address, + name: typeof payload.name === 'string' && payload.name.trim().length > 0 ? payload.name.trim() : undefined, + updatedAt: typeof payload.updatedAt === 'number' ? payload.updatedAt : Date.now(), + updatedBy: + typeof payload.updatedBy === 'string' && payload.updatedBy.trim().length > 0 ? payload.updatedBy.trim() : undefined, } } catch (error) { - console.warn('Failed to parse sandbox node binding', error) + console.warn('Failed to fetch sandbox node binding', error) + return null } - - return null } - -export function setSandboxNodeBinding(binding: { address: string; name?: string; updatedBy?: string }) { - if (typeof window === 'undefined') { - return - } - - const payload: SandboxNodeBinding = { - address: binding.address.trim(), - name: binding.name?.trim() || undefined, - updatedAt: Date.now(), - updatedBy: binding.updatedBy?.trim() || undefined, - } - - window.localStorage.setItem(SANDBOX_NODE_BINDING_KEY, JSON.stringify(payload)) -} - -export function clearSandboxNodeBinding() { - if (typeof window === 'undefined') { - return - } - window.localStorage.removeItem(SANDBOX_NODE_BINDING_KEY) -} - diff --git a/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx b/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx index 70ba0b6..724a0ca 100644 --- a/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx +++ b/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx @@ -5,11 +5,6 @@ import useSWR from 'swr' import Card from '../../components/Card' import type { VlessNode } from '../../lib/vless' -import { - clearSandboxNodeBinding, - getSandboxNodeBinding, - setSandboxNodeBinding, -} from '../../lib/sandboxNodeBinding' async function fetcher(url: string): Promise { const response = await fetch(url, { @@ -44,8 +39,8 @@ export default function SandboxNodeBindingPanel() { }) const [message, setMessage] = useState(null) - const currentBinding = useMemo(() => getSandboxNodeBinding(), []) - const [draftAddress, setDraftAddress] = useState(currentBinding?.address ?? '') + const [activeBinding, setActiveBinding] = useState<{ address: string; updatedAt?: number } | null>(null) + const [draftAddress, setDraftAddress] = useState('') const [isSaving, setIsSaving] = useState(false) useEffect(() => { @@ -55,23 +50,18 @@ export default function SandboxNodeBindingPanel() { .then(data => { if (data && typeof data.address === 'string') { setDraftAddress(data.address) - if (data.address) { - setSandboxNodeBinding({ - address: data.address, - updatedBy: 'server' - }) - } else { - clearSandboxNodeBinding() - } + setActiveBinding({ + address: data.address, + updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : undefined, + }) } }) .catch(err => console.error('Failed to fetch binding from server', err)) }, []) const isChanged = useMemo(() => { - const current = getSandboxNodeBinding() - return (current?.address ?? '') !== draftAddress - }, [draftAddress]) + return (activeBinding?.address ?? '') !== draftAddress + }, [activeBinding?.address, draftAddress]) const handleApply = async (rawAddress: string) => { const address = rawAddress.trim() @@ -92,15 +82,11 @@ export default function SandboxNodeBindingPanel() { } if (!address) { - clearSandboxNodeBinding() + setActiveBinding({ address: '', updatedAt: Date.now() }) setMessage('已成功清空绑定节点 (已同步至服务器)') } else { const node = nodes?.find((item) => item.address === address) - setSandboxNodeBinding({ - address: address, - name: node?.name || address, - updatedBy: 'root', - }) + setActiveBinding({ address, updatedAt: Date.now() }) setMessage(`应用成功:已绑定至 ${node?.name || address} (已同步至服务器)`) } @@ -112,7 +98,7 @@ export default function SandboxNodeBindingPanel() { } } - const currentActive = getSandboxNodeBinding() + const currentActive = activeBinding?.address ? activeBinding : null return ( @@ -163,7 +149,7 @@ export default function SandboxNodeBindingPanel() { {currentActive ? (
- 当前活跃绑定:{currentActive.name || currentActive.address} + 当前活跃绑定:{currentActive.address}
) : (
@@ -171,11 +157,11 @@ export default function SandboxNodeBindingPanel() { 当前未绑定任何节点
)} - {currentActive?.updatedAt && ( + {currentActive?.updatedAt ? (

最后更新时间:{new Date(currentActive.updatedAt).toLocaleString()}

- )} + ) : null}
{error &&

⚠️ 节点列表加载失败:{error.message}

} diff --git a/src/modules/extensions/builtin/user-center/routes/agent.tsx b/src/modules/extensions/builtin/user-center/routes/agent.tsx index cd44404..8788d92 100644 --- a/src/modules/extensions/builtin/user-center/routes/agent.tsx +++ b/src/modules/extensions/builtin/user-center/routes/agent.tsx @@ -8,7 +8,7 @@ import Breadcrumbs from '@/app/panel/components/Breadcrumbs' import { useLanguage } from '@i18n/LanguageProvider' import { translations } from '@i18n/translations' import { useUserStore } from '@lib/userStore' -import { getSandboxNodeBinding } from '../lib/sandboxNodeBinding' +import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding' interface VlessNode { name: string @@ -67,32 +67,43 @@ export default function UserCenterAgentRoute() { const isGuestSandboxReadOnly = Boolean( user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus' || normalizedEmail === 'demo@svc.plus'), ) + const [boundAddress, setBoundAddress] = useState(null) useEffect(() => { if (!isGuestSandboxReadOnly) { setBoundNode(null) + setBoundAddress(null) return } - const binding = getSandboxNodeBinding() - if (!binding) { - setBoundNode(null) - return + let cancelled = false + void (async () => { + const binding = await fetchSandboxNodeBinding() + if (cancelled) { + return + } + setBoundAddress(binding?.address ?? null) + if (!binding?.address) { + setBoundNode(null) + return + } + setBoundNode({ + name: binding.name || 'Sandbox Node', + address: binding.address, + port: 443, + transport: 'tcp', + security: 'tls', + } as any) + })() + return () => { + cancelled = true } - setBoundNode({ - name: binding.name || 'Sandbox Node', - address: binding.address, - port: 443, - transport: 'tcp', - security: 'tls', - } as any) }, [isGuestSandboxReadOnly]) const effectiveNodes = useMemo(() => { // 1. If we have a bound node address (from root management), try to find it in the full list if (isGuestSandboxReadOnly && normalizedEmail) { - const binding = getSandboxNodeBinding() - if (binding?.address && nodes?.length) { - const matched = nodes.find((n) => n.address === binding.address) + if (boundAddress && nodes?.length) { + const matched = nodes.find((n) => n.address === boundAddress) if (matched) { return [matched] } @@ -106,7 +117,7 @@ export default function UserCenterAgentRoute() { // 3. No fallback logic return [] - }, [isGuestSandboxReadOnly, nodes, visibleNodes, normalizedEmail]) + }, [isGuestSandboxReadOnly, nodes, visibleNodes, normalizedEmail, boundAddress]) const groupedNodes = useMemo(() => { const groups: Record = {