fix: 修复云端聊天鉴权及容器兼容并支持3200原地升级

This commit is contained in:
NathanAlex7
2026-09-14 11:53:29 +08:00
parent af1a1027d7
commit c556172a1d
11 changed files with 97 additions and 13 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ RUN mkdir -p /app/backend/xingbang_data/reviewers \
/app/backend/logs /app/backend/logs
COPY docker-entrypoint.sh /app/docker-entrypoint.sh COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh RUN sed -i 's/\r$//' /app/docker-entrypoint.sh && chmod +x /app/docker-entrypoint.sh
EXPOSE 8002 3000 EXPOSE 8002 3000
+3
View File
@@ -8,6 +8,9 @@ ROOT = Path(__file__).resolve().parents[1]
class DeploymentReleaseTests(unittest.TestCase): class DeploymentReleaseTests(unittest.TestCase):
def test_container_entrypoint_normalizes_windows_line_endings(self):
self.assertIn("sed -i 's/\\r$//' /app/docker-entrypoint.sh", (ROOT / "Dockerfile").read_text(encoding="utf-8"))
def test_cloud_ports_remain_loopback_only(self): def test_cloud_ports_remain_loopback_only(self):
compose = (ROOT / "deploy/docker-compose.aliyun.yml").read_text(encoding="utf-8") compose = (ROOT / "deploy/docker-compose.aliyun.yml").read_text(encoding="utf-8")
self.assertIn('"127.0.0.1:${FRONTEND_PORT:-80}:3000"', compose) self.assertIn('"127.0.0.1:${FRONTEND_PORT:-80}:3000"', compose)
+10
View File
@@ -33,3 +33,13 @@ backend/test_chat_repair.py覆盖空响应、最终正文补发、工具分段
scripts/probe_chat_model.py为手动最小诊断工具,使用既有配置只发送连通性文本,不发送项目、附件、历史或工具;输出仅为字段计数、HTTP状态和时间,不打印凭据或推理内容。使用时会产生真实模型调用,单元测试不执行它。 scripts/probe_chat_model.py为手动最小诊断工具,使用既有配置只发送连通性文本,不发送项目、附件、历史或工具;输出仅为字段计数、HTTP状态和时间,不打印凭据或推理内容。使用时会产生真实模型调用,单元测试不执行它。
统一验证日志:test-evidence/20260911-154025-c57d291,103个后端测试、前端构建、两套Compose配置通过。浏览器验收结果另外保存在test-evidence/chat-repair/。 统一验证日志:test-evidence/20260911-154025-c57d291,103个后端测试、前端构建、两套Compose配置通过。浏览器验收结果另外保存在test-evidence/chat-repair/。
## 2026-09-14:生产容器兼容修复
生产模式下 Next.js 可用监听地址 0.0.0.0 构造 nextUrl,不能将 nextUrl.hostname 当成浏览器 Host 或认证依据。生产环境的通用聊天、文件与会话代理现在一律先通过既有 access/context 校验管理员身份,预览身份、普通业务角色及未认证请求均被拒绝;不会通过放宽 Host 限制绕过鉴权。
原始聊天客户端和 SSE 统一复用当前标签页 sessionStorage 中的 xingbang-access-token。先在 `/data-access` 的“访问身份”连接管理员令牌,再返回聊天首页即可;不改变现有账户或令牌。开发模式在未配置身份时仍仅接受本机 Host,并拒绝跨站请求。
同时补齐镜像启动脚本 CRLF 转换和 HTTP 页面请求 UUID 兼容。原有依赖版本未升级,已记录的依赖安全告警仍需后续单独处理;生产访问应使用受控网络或 HTTPS。
验证:test-evidence/20260914-114958-af1a102,108 项后端测试、前端构建及两套部署配置校验通过;云端容器实测及切换记录保存在 test-evidence/aliyun-switch-20260914。
+35 -5
View File
@@ -4,10 +4,10 @@ const path = require('node:path');
const vm = require('node:vm'); const vm = require('node:vm');
const ts = require('typescript'); const ts = require('typescript');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
function load(relative, overrides = {}) { function load(relative, overrides = {}, globals = {}) {
const compiled = ts.transpileModule(fs.readFileSync(path.join(root,relative),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText; const compiled = ts.transpileModule(fs.readFileSync(path.join(root,relative),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText;
const exports = {}; const exports = {};
vm.runInNewContext(compiled,{exports,require:id=>overrides[id]||require(id),process,fetch:global.fetch,Headers,Response,AbortController,TextDecoder,TextEncoder,setTimeout,clearTimeout,URL}); vm.runInNewContext(compiled,{exports,require:id=>overrides[id]||require(id),process,fetch:global.fetch,Headers,Response,AbortController,TextDecoder,TextEncoder,setTimeout,clearTimeout,URL,...globals});
return exports; return exports;
} }
const {ChatEventDecoder,readChatStream} = load('src/lib/chat-stream.ts'); const {ChatEventDecoder,readChatStream} = load('src/lib/chat-stream.ts');
@@ -44,19 +44,49 @@ function responder(chunks) {return async()=>new Response(new ReadableStream({sta
const NextResponse = class extends Response {static json(data,init){return new Response(JSON.stringify(data),{...init,headers:{'Content-Type':'application/json'}});}}; const NextResponse = class extends Response {static json(data,init){return new Response(JSON.stringify(data),{...init,headers:{'Content-Type':'application/json'}});}};
const oldFetch=global.fetch, oldBase=process.env.RISK_AGENT_BACKEND_URL, oldCore=process.env.CORE_BACKEND_URL; const oldFetch=global.fetch, oldBase=process.env.RISK_AGENT_BACKEND_URL, oldCore=process.env.CORE_BACKEND_URL;
const savedEnv = Object.fromEntries(['NODE_ENV','XINGBANG_REQUIRE_AUTH','XINGBANG_AUTH_USERS'].map(k=>[k,process.env[k]]));
try { try {
process.env.NODE_ENV='development';delete process.env.XINGBANG_REQUIRE_AUTH;delete process.env.XINGBANG_AUTH_USERS;
delete process.env.CORE_BACKEND_URL;process.env.RISK_AGENT_BACKEND_URL='http://127.0.0.1:8003'; delete process.env.CORE_BACKEND_URL;process.env.RISK_AGENT_BACKEND_URL='http://127.0.0.1:8003';
let target, options; let target, options;
const upstream=new Response(new ReadableStream({start(c){c.enqueue(encode(event('token',{content:'stream'})));}}),{headers:{'Content-Type':'text/event-stream'}}); const upstream=new Response(new ReadableStream({start(c){c.enqueue(encode(event('token',{content:'stream'})));}}),{headers:{'Content-Type':'text/event-stream'}});
global.fetch=async(url,opts)=>{target=url;options=opts;return upstream;}; global.fetch=async(url,opts)=>{target=url;options=opts;return upstream;};
const route=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}}); const route=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}});
const request={nextUrl:{hostname:'127.0.0.1',search:'?id=abc'},headers:new Headers(),method:'POST',arrayBuffer:async()=>encode('{}'),signal:new AbortController().signal}; const request={nextUrl:{hostname:'0.0.0.0',search:'?id=abc'},headers:new Headers({Host:'127.0.0.1:3000'}),method:'POST',arrayBuffer:async()=>encode('{}'),signal:new AbortController().signal};
const response=await route.POST(request,{params:{path:['chat']}}); const response=await route.POST(request,{params:{path:['chat']}});
assert.equal(target,'http://127.0.0.1:8003/api/chat?id=abc');assert.equal(response.body,upstream.body);assert.equal(options.signal,request.signal); assert.equal(target,'http://127.0.0.1:8003/api/chat?id=abc');assert.equal(response.body,upstream.body);assert.equal(options.signal,request.signal);
const reader=response.body.getReader();assert.ok((await reader.read()).value.length);await reader.cancel(); const reader=response.body.getReader();assert.ok((await reader.read()).value.length);await reader.cancel();
assert.equal((await route.GET({...request,nextUrl:{hostname:'example.org'}},{params:{path:['chat']}})).status,403); assert.equal((await route.GET({...request,headers:new Headers({Host:'example.org'})},{params:{path:['chat']}})).status,403);
assert.equal((await route.GET({...request,headers:new Headers({Host:'localhost:3000',Origin:'https://evil.example'})},{params:{path:['chat']}})).status,403);
assert.equal((await route.GET(request,{params:{path:['chat','..']}})).status,404); assert.equal((await route.GET(request,{params:{path:['chat','..']}})).status,404);
assert.equal((await route.GET(request,{params:{path:['unknown']}})).status,404); assert.equal((await route.GET(request,{params:{path:['unknown']}})).status,404);
} finally {global.fetch=oldFetch;if(oldBase===undefined)delete process.env.RISK_AGENT_BACKEND_URL;else process.env.RISK_AGENT_BACKEND_URL=oldBase;if(oldCore===undefined)delete process.env.CORE_BACKEND_URL;else process.env.CORE_BACKEND_URL=oldCore;} process.env.NODE_ENV='production';
assert.equal((await route.GET(request,{params:{path:['sessions']}})).status,401);
for (const identity of [{id:'a',role:'admin'},{id:'r',role:'reviewer'},{id:'m',role:'manager'},{id:'p',role:'admin',preview:true},{}]) {
let forwarded=false;
global.fetch=async(url,opts)=>{
if(url.endsWith('/access/context')) {assert.equal(opts.headers.Authorization,'Bearer test-admin');return Response.json(identity);}
forwarded=true;return new Response(event('done',{content:'ok'}),{headers:{'Content-Type':'text/event-stream'}});
};
const secured=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}});
const r=await secured.POST({...request,headers:new Headers({Host:'cloud.example:3200',Authorization:'Bearer test-admin'})},{params:{path:['chat']}});
const permitted=identity.role==='admin'&&!identity.preview&&!!identity.id;
assert.equal(r.status,permitted?200:403);assert.equal(forwarded,permitted);
}
for (const status of [401,403,500]) {
global.fetch=async()=>new Response('{}',{status});
const secured=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}});
assert.equal((await secured.GET({...request,headers:new Headers({Authorization:'Bearer invalid'})},{params:{path:['sessions']}})).status,status===500?503:status);
}
let forwardedHeaders;
global.fetch=async(_url,opts)=>{forwardedHeaders=opts.headers;return Response.json({sessions:[]});};
const {coreFetch}=load('src/lib/core-fetch.ts',{}, {window:{sessionStorage:{getItem:key=>key==='xingbang-access-token'?'existing-token':null}}});
await coreFetch('/api/core/sessions',{headers:{'Content-Type':'application/json'}});
assert.equal(forwardedHeaders.get('Authorization'),'Bearer existing-token');
assert.equal(forwardedHeaders.get('Content-Type'),'application/json');
assert.ok(source.includes('{fetcher:fetch}'));assert.ok(source.includes('coreFetch as fetch'));
const {newRequestId}=load('src/lib/request-id.ts',{}, {crypto:{getRandomValues:require('node:crypto').webcrypto.getRandomValues.bind(require('node:crypto').webcrypto)}});
assert.match(newRequestId(),/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/);assert.notEqual(newRequestId(),newRequestId());
} finally {for(const [k,v] of Object.entries(savedEnv)){if(v===undefined)delete process.env[k];else process.env[k]=v;}global.fetch=oldFetch;if(oldBase===undefined)delete process.env.RISK_AGENT_BACKEND_URL;else process.env.RISK_AGENT_BACKEND_URL=oldBase;if(oldCore===undefined)delete process.env.CORE_BACKEND_URL;else process.env.CORE_BACKEND_URL=oldCore;}
console.log('PASS: all SSE split boundaries, CRLF, multiline, final-only, empty, timeout, premature EOF, errors, streaming proxy and local-only guard'); console.log('PASS: all SSE split boundaries, CRLF, multiline, final-only, empty, timeout, premature EOF, errors, streaming proxy and local-only guard');
})().catch(error=>{console.error(error);process.exitCode=1;}); })().catch(error=>{console.error(error);process.exitCode=1;});
+26 -4
View File
@@ -5,15 +5,37 @@ export const runtime = "nodejs";
const allowed = new Set(["chat", "sessions", "files", "skills", "tokens", "config", "xingbang"]); const allowed = new Set(["chat", "sessions", "files", "skills", "tokens", "config", "xingbang"]);
async function proxy(request:NextRequest, {params}:{params:{path:string[]}}) { async function proxy(request:NextRequest, {params}:{params:{path:string[]}}) {
// General chat has filesystem/tools, unlike the scoped project APIs: local only.
if (!["localhost","127.0.0.1","::1","[::1]"].includes(request.nextUrl.hostname))
return NextResponse.json({detail:"原始开发聊天仅允许本机访问"},{status:403});
if (!allowed.has(params.path[0]) || params.path.some(p=>!p || p==="." || p===".." || /[\\/]/.test(p))) if (!allowed.has(params.path[0]) || params.path.some(p=>!p || p==="." || p===".." || /[\\/]/.test(p)))
return NextResponse.json({detail:"接口不存在"},{status:404}); return NextResponse.json({detail:"接口不存在"},{status:404});
const base = process.env.CORE_BACKEND_URL || process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002"; const base = process.env.CORE_BACKEND_URL || process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
try { try {
// Production nextUrl can contain the bind address (0.0.0.0), not the client Host.
// Never use Host as authentication: all production core/tool access requires admin.
const authorization = request.headers.get("authorization") || "";
const host = request.headers.get("host") || "";
const requiresAuth = process.env.NODE_ENV === "production" || process.env.XINGBANG_REQUIRE_AUTH === "1" || !!process.env.XINGBANG_AUTH_USERS || !!authorization;
if (requiresAuth) {
if (!authorization.startsWith("Bearer ") || !authorization.slice(7).trim())
return NextResponse.json({detail:"请先在风险研判页面连接管理员访问身份(401)"},{status:401});
const identity = await fetch(`${base}/api/xingbang/risk-agent/access/context`, {
headers:{Authorization:authorization,"X-Xingbang-Client-Host":host},
cache:"no-store",redirect:"error",signal:request.signal,
});
if (!identity.ok) return NextResponse.json({detail:"访问身份校验失败"},{status:identity.status === 401 || identity.status === 403 ? identity.status : 503});
const user = await identity.json();
if (user.role !== "admin" || user.preview || !user.id)
return NextResponse.json({detail:"通用聊天与文件工具仅限已认证管理员"},{status:403});
} else {
let hostname = "";
try { hostname = new URL(`http://${host}`).hostname; } catch { /* reject malformed Host */ }
if (!["localhost","127.0.0.1","::1","[::1]"].includes(hostname))
return NextResponse.json({detail:"未配置身份认证,仅允许本机开发访问"},{status:403});
const origin = request.headers.get("origin");
if ((origin && new URL(origin).host !== host) || request.headers.get("sec-fetch-site") === "cross-site")
return NextResponse.json({detail:"不允许跨站访问开发工具"},{status:403});
}
const upstream = await fetch(`${base}/api/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`, { const upstream = await fetch(`${base}/api/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`, {
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json"}, method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json",Authorization:authorization},
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(), body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),
cache:"no-store",redirect:"error",signal:request.signal, cache:"no-store",redirect:"error",signal:request.signal,
}); });
+2 -1
View File
@@ -3,6 +3,7 @@
import {useEffect, useRef, useState} from "react"; import {useEffect, useRef, useState} from "react";
import {Activity, ArrowRight, CheckCircle2, Database, FlaskConical, Play, RefreshCw, ShieldCheck, SlidersHorizontal, Square, X} from "lucide-react"; import {Activity, ArrowRight, CheckCircle2, Database, FlaskConical, Play, RefreshCw, ShieldCheck, SlidersHorizontal, Square, X} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav"; import AgentNav from "@/components/xingbang/AgentNav";
import {newRequestId} from "@/lib/request-id";
import "./operations.css"; import "./operations.css";
const ROOT = "/api/risk-agent/project-assistant/operations"; const ROOT = "/api/risk-agent/project-assistant/operations";
@@ -53,7 +54,7 @@ export default function OperationsCenter() {
async function act(fn:()=>Promise<void>){setBusy(true);setError("");setNotice("");try{await fn();setOverview(await api(`/overview?domain=${domain}`));}catch(e:any){setError(e.message);}finally{setBusy(false);}} async function act(fn:()=>Promise<void>){setBusy(true);setError("");setNotice("");try{await fn();setOverview(await api(`/overview?domain=${domain}`));}catch(e:any){setError(e.message);}finally{setBusy(false);}}
function edit(key:string,value:any){setForm((f:any)=>({...f,[key]:value}));setDirty(true);} function edit(key:string,value:any){setForm((f:any)=>({...f,[key]:value}));setDirty(true);}
function chooseView(next:string){setView(next);history.replaceState({},"",`?domain=${domain}&view=${next}`);} function chooseView(next:string){setView(next);history.replaceState({},"",`?domain=${domain}&view=${next}`);}
async function submit(){await act(async()=>{const run=await api("/lab/runs",{source_revision:source.project.revision,model,requirement:model?requirement:""},crypto.randomUUID());setSelected(run.id);setView("monitor");await loadSource();setNotice("真实任务已入队。执行记录会自动刷新;历史快照保持不变。");});} async function submit(){await act(async()=>{const run=await api("/lab/runs",{source_revision:source.project.revision,model,requirement:model?requirement:""},newRequestId());setSelected(run.id);setView("monitor");await loadSource();setNotice("真实任务已入队。执行记录会自动刷新;历史快照保持不变。");});}
const runs=(overview?.runs||[]).filter((r:any)=>(status==="all"||r.status===status)&&(!search||`${r.project_id} ${r.id}`.toLowerCase().includes(search.toLowerCase()))); const runs=(overview?.runs||[]).filter((r:any)=>(status==="all"||r.status===status)&&(!search||`${r.project_id} ${r.id}`.toLowerCase().includes(search.toLowerCase())));
const run=detail?.run; const run=detail?.run;
const oldChecks=new Map<string,any>((comparison?.run?.checks||[]).map((c:any)=>[c.code,c])); const oldChecks=new Map<string,any>((comparison?.run?.checks||[]).map((c:any)=>[c.code,c]));
+2 -1
View File
@@ -4,6 +4,7 @@ import {ArrowRight, FileText, RefreshCw, ShieldCheck} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav"; import AgentNav from "@/components/xingbang/AgentNav";
import AssistantConfiguration from "@/components/xingbang/AssistantConfiguration"; import AssistantConfiguration from "@/components/xingbang/AssistantConfiguration";
import AssistantHistory from "@/components/xingbang/AssistantHistory"; import AssistantHistory from "@/components/xingbang/AssistantHistory";
import {newRequestId} from "@/lib/request-id";
import "../data-access/workspace.css"; import "../data-access/workspace.css";
const tabs = [["overview", "概览"], ["findings", "材料与问题"], ["scenario", "方案比较"], ["report", "报告底稿"], ["experience", "反馈与经验"]]; const tabs = [["overview", "概览"], ["findings", "材料与问题"], ["scenario", "方案比较"], ["report", "报告底稿"], ["experience", "反馈与经验"]];
@@ -22,7 +23,7 @@ export default function XingbangWorkspace() {
const selected = useRef(""); const selected = useRef("");
const headers = (json = true) => {const token = sessionStorage.getItem("xingbang-access-token"); return {...(json ? {"Content-Type": "application/json"} : {}), ...(token ? {Authorization: `Bearer ${token}`} : {})};}; const headers = (json = true) => {const token = sessionStorage.getItem("xingbang-access-token"); return {...(json ? {"Content-Type": "application/json"} : {}), ...(token ? {Authorization: `Bearer ${token}`} : {})};};
async function request(path: string, body?: any) { async function request(path: string, body?: any) {
const response = await fetch(`/api/risk-agent/project-assistant${path}`, {method: body === undefined ? "GET" : "POST", headers: {...headers(), ...(/\/(runs|reports)$/.test(path) && body ? {"Idempotency-Key": crypto.randomUUID()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)}); const response = await fetch(`/api/risk-agent/project-assistant${path}`, {method: body === undefined ? "GET" : "POST", headers: {...headers(), ...(/\/(runs|reports)$/.test(path) && body ? {"Idempotency-Key": newRequestId()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)});
const data = await response.json(); const data = await response.json();
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || "操作失败")); if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || "操作失败"));
return data; return data;
+1
View File
@@ -49,6 +49,7 @@ export default function ChatInput() {
</div> </div>
<p className="text-center text-[10px] text-gray-400/70 mt-2"> <p className="text-center text-[10px] text-gray-400/70 mt-2">
Powered by DeepSeek · mini OpenClaw v0.1 Powered by DeepSeek · mini OpenClaw v0.1
{" · "}<a href="/data-access" className="underline">云端聊天:先连接管理员访问身份</a>
</p> </p>
</div> </div>
); );
+2 -1
View File
@@ -4,6 +4,7 @@
*/ */
import {readChatStream} from "./chat-stream"; import {readChatStream} from "./chat-stream";
import {coreFetch as fetch} from "./core-fetch";
// Same-origin streaming proxy shares the current workbench backend configuration. // Same-origin streaming proxy shares the current workbench backend configuration.
const API_BASE = "/api/core"; const API_BASE = "/api/core";
@@ -21,7 +22,7 @@ export async function* streamChat(
message: string, message: string,
sessionId: string sessionId: string
): AsyncGenerator<SSEEvent> { ): AsyncGenerator<SSEEvent> {
yield* readChatStream(`${API_BASE}/chat`, {message,session_id:sessionId,stream:true}); yield* readChatStream(`${API_BASE}/chat`, {message,session_id:sessionId,stream:true}, {fetcher:fetch});
} }
/** /**
+7
View File
@@ -0,0 +1,7 @@
/** Reuse the existing access identity without putting credentials in URLs or logs. */
export const coreFetch: typeof fetch = (input, init) => {
const headers = new Headers(init?.headers);
const token = typeof window === "undefined" ? null : window.sessionStorage.getItem("xingbang-access-token");
if (token) headers.set("Authorization", `Bearer ${token}`);
return globalThis.fetch(input, {...init, headers});
};
+8
View File
@@ -0,0 +1,8 @@
/** getRandomValues also works on HTTP intranet pages where randomUUID is unavailable. */
export function newRequestId(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}