diff --git a/Dockerfile b/Dockerfile index 32bc819..9c74dee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,7 @@ RUN mkdir -p /app/backend/xingbang_data/reviewers \ /app/backend/logs 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 diff --git a/backend/test_deployment_release.py b/backend/test_deployment_release.py index 9afb835..dc4c41b 100644 --- a/backend/test_deployment_release.py +++ b/backend/test_deployment_release.py @@ -8,6 +8,9 @@ ROOT = Path(__file__).resolve().parents[1] 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): compose = (ROOT / "deploy/docker-compose.aliyun.yml").read_text(encoding="utf-8") self.assertIn('"127.0.0.1:${FRONTEND_PORT:-80}:3000"', compose) diff --git a/docs/聊天连接修复说明.md b/docs/聊天连接修复说明.md index dbeff73..a008bdb 100644 --- a/docs/聊天连接修复说明.md +++ b/docs/聊天连接修复说明.md @@ -33,3 +33,13 @@ backend/test_chat_repair.py覆盖空响应、最终正文补发、工具分段 scripts/probe_chat_model.py为手动最小诊断工具,使用既有配置只发送连通性文本,不发送项目、附件、历史或工具;输出仅为字段计数、HTTP状态和时间,不打印凭据或推理内容。使用时会产生真实模型调用,单元测试不执行它。 统一验证日志: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。 diff --git a/scripts/test_chat_stream.cjs b/scripts/test_chat_stream.cjs index d755b56..e386798 100644 --- a/scripts/test_chat_stream.cjs +++ b/scripts/test_chat_stream.cjs @@ -4,10 +4,10 @@ const path = require('node:path'); const vm = require('node:vm'); const ts = require('typescript'); 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 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; } 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 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 { + 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'; let target, options; 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;}; 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']}}); 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(); - 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:['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'); })().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/src/app/api/core/[...path]/route.ts b/src/app/api/core/[...path]/route.ts index 55e06b5..40597c6 100644 --- a/src/app/api/core/[...path]/route.ts +++ b/src/app/api/core/[...path]/route.ts @@ -5,15 +5,37 @@ export const runtime = "nodejs"; const allowed = new Set(["chat", "sessions", "files", "skills", "tokens", "config", "xingbang"]); 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))) 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"; 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}`, { - 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(), cache:"no-store",redirect:"error",signal:request.signal, }); diff --git a/src/app/operations-center/page.tsx b/src/app/operations-center/page.tsx index 6a1df50..83d2e2b 100644 --- a/src/app/operations-center/page.tsx +++ b/src/app/operations-center/page.tsx @@ -3,6 +3,7 @@ import {useEffect, useRef, useState} from "react"; import {Activity, ArrowRight, CheckCircle2, Database, FlaskConical, Play, RefreshCw, ShieldCheck, SlidersHorizontal, Square, X} from "lucide-react"; import AgentNav from "@/components/xingbang/AgentNav"; +import {newRequestId} from "@/lib/request-id"; import "./operations.css"; const ROOT = "/api/risk-agent/project-assistant/operations"; @@ -53,7 +54,7 @@ export default function OperationsCenter() { async function act(fn:()=>Promise){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 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 run=detail?.run; const oldChecks=new Map((comparison?.run?.checks||[]).map((c:any)=>[c.code,c])); diff --git a/src/app/xingbang-workbench/page.tsx b/src/app/xingbang-workbench/page.tsx index f76c5f5..277d763 100644 --- a/src/app/xingbang-workbench/page.tsx +++ b/src/app/xingbang-workbench/page.tsx @@ -4,6 +4,7 @@ import {ArrowRight, FileText, RefreshCw, ShieldCheck} from "lucide-react"; import AgentNav from "@/components/xingbang/AgentNav"; import AssistantConfiguration from "@/components/xingbang/AssistantConfiguration"; import AssistantHistory from "@/components/xingbang/AssistantHistory"; +import {newRequestId} from "@/lib/request-id"; import "../data-access/workspace.css"; const tabs = [["overview", "概览"], ["findings", "材料与问题"], ["scenario", "方案比较"], ["report", "报告底稿"], ["experience", "反馈与经验"]]; @@ -22,7 +23,7 @@ export default function XingbangWorkspace() { const selected = useRef(""); 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) { - 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(); if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || "操作失败")); return data; diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index fd57138..a120820 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -49,6 +49,7 @@ export default function ChatInput() {

Powered by DeepSeek · mini OpenClaw v0.1 + {" · "}云端聊天:先连接管理员访问身份

); diff --git a/src/lib/api.ts b/src/lib/api.ts index af6cbc5..27dd724 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,6 +4,7 @@ */ import {readChatStream} from "./chat-stream"; +import {coreFetch as fetch} from "./core-fetch"; // Same-origin streaming proxy shares the current workbench backend configuration. const API_BASE = "/api/core"; @@ -21,7 +22,7 @@ export async function* streamChat( message: string, sessionId: string ): AsyncGenerator { - yield* readChatStream(`${API_BASE}/chat`, {message,session_id:sessionId,stream:true}); + yield* readChatStream(`${API_BASE}/chat`, {message,session_id:sessionId,stream:true}, {fetcher:fetch}); } /** diff --git a/src/lib/core-fetch.ts b/src/lib/core-fetch.ts new file mode 100644 index 0000000..c8f3b1a --- /dev/null +++ b/src/lib/core-fetch.ts @@ -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}); +}; diff --git a/src/lib/request-id.ts b/src/lib/request-id.ts new file mode 100644 index 0000000..8e07d6e --- /dev/null +++ b/src/lib/request-id.ts @@ -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)}`; +}