fix: 修复云端聊天鉴权及容器兼容并支持3200原地升级
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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;});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<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 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<string,any>((comparison?.run?.checks||[]).map((c:any)=>[c.code,c]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function ChatInput() {
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-gray-400/70 mt-2">
|
||||
Powered by DeepSeek · mini OpenClaw v0.1
|
||||
{" · "}<a href="/data-access" className="underline">云端聊天:先连接管理员访问身份</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
-1
@@ -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<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});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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});
|
||||
};
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user