fix(security): add CORS allowlist and enforce principal on chat handlers

Replace wildcard CORS with configured origins and bind chat/task user
identity to the authenticated principal instead of request body fields.
This commit is contained in:
weichen
2026-09-13 02:50:22 +00:00
committed by weichen
parent d612c1193e
commit 171959710d
8 changed files with 129 additions and 13 deletions
+7
View File
@@ -76,3 +76,10 @@ ADMIN_PASSWORD=admin-secret-password-123
# 全局准入策略 (是否开放自主注册与注册是否必须审批)
AUTH_REGISTRATION_ENABLED=true
AUTH_REQUIRE_APPROVAL=false
# CORS 允许来源(逗号分隔);main 环境必须显式配置,禁止 *
# dev/test 未配置时使用 localhost 默认值
# CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:8080
# 生产环境必须为 false;main 启动时若 true 将拒绝加载配置
# ALLOW_IN_MEMORY=false
+43
View File
@@ -61,6 +61,9 @@ type Config struct {
// AllowInMemory 显式允许数据库不可用时降级内存模式(仅限本地 Demo)
AllowInMemory bool `json:"allow_in_memory"`
// CORSAllowedOrigins 允许的浏览器 Origin 列表;空列表时不回显 Access-Control-Allow-Origin
CORSAllowedOrigins []string `json:"cors_allowed_origins"`
}
func Load(path string) (*Config, error) {
@@ -222,6 +225,8 @@ func Load(path string) (*Config, error) {
}
case "ALLOW_IN_MEMORY":
cfg.AllowInMemory = val == "true" || val == "1"
case "CORS_ALLOWED_ORIGINS":
cfg.CORSAllowedOrigins = parseCSVOrigins(val)
}
}
}
@@ -327,6 +332,12 @@ func Load(path string) (*Config, error) {
if v := os.Getenv("PI_AGENT_RUNNER_URL"); v != "" {
cfg.PiAgentRunnerURL = v
}
if v := os.Getenv("ALLOW_IN_MEMORY"); v != "" {
cfg.AllowInMemory = v == "true" || v == "1"
}
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
cfg.CORSAllowedOrigins = parseCSVOrigins(v)
}
if v := os.Getenv("HTTP_ADDR"); v != "" && strings.HasPrefix(v, ":") {
if p, err := strconv.Atoi(strings.TrimPrefix(v, ":")); err == nil {
cfg.HTTPPort = p
@@ -334,6 +345,7 @@ func Load(path string) (*Config, error) {
}
}
applyDSNOverrides(cfg)
applyCORSDefaults(cfg)
if err := validateProductionSecrets(cfg); err != nil {
return nil, err
@@ -342,6 +354,34 @@ func Load(path string) (*Config, error) {
return cfg, nil
}
func parseCSVOrigins(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func applyCORSDefaults(cfg *Config) {
if cfg == nil || len(cfg.CORSAllowedOrigins) > 0 {
return
}
if cfg.Env == "dev" || cfg.Env == "test" {
cfg.CORSAllowedOrigins = []string{
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:8080",
"http://127.0.0.1:8080",
"http://localhost:8081",
"http://127.0.0.1:8081",
}
}
}
func validateProductionSecrets(cfg *Config) error {
if cfg == nil || cfg.Env == "" || cfg.Env == "dev" || cfg.Env == "test" {
return nil
@@ -362,6 +402,9 @@ func validateProductionSecrets(cfg *Config) error {
}
}
}
if cfg.AllowInMemory {
return fmt.Errorf("ALLOW_IN_MEMORY must be false in %s environment", cfg.Env)
}
return nil
}
+23
View File
@@ -69,6 +69,29 @@ func TestConfigLoadDSNOverrides(t *testing.T) {
}
}
func TestConfigLoadCORSDefaultsForDev(t *testing.T) {
cfg, err := config.Load("/dev/null")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.CORSAllowedOrigins) == 0 {
t.Fatal("expected default CORS origins for dev")
}
}
func TestConfigLoadRejectsAllowInMemoryInMain(t *testing.T) {
t.Setenv("ENV", "main")
t.Setenv("KB_DB_PASSWORD", "kb-prod-secret-xyz")
t.Setenv("CONTEXT_DB_PASSWORD", "ctx-prod-secret-xyz")
t.Setenv("ADMIN_PASSWORD", "admin-prod-secret-xyz")
t.Setenv("ALLOW_IN_MEMORY", "true")
_, err := config.Load("/dev/null")
if err == nil {
t.Fatal("expected config load to fail when ALLOW_IN_MEMORY=true in main")
}
}
func TestConfigLoadRejectsWeakSecretsOutsideDev(t *testing.T) {
t.Setenv("ENV", "main")
t.Setenv("KB_DB_PASSWORD", "")
+27
View File
@@ -0,0 +1,27 @@
package server
import (
"net/http"
"strings"
)
func applyCORSHeaders(w http.ResponseWriter, r *http.Request, allowedOrigins []string) {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || len(allowedOrigins) == 0 {
return
}
for _, allowed := range allowedOrigins {
if strings.TrimSpace(allowed) == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
return
}
}
}
func (s *Server) writeCORS(w http.ResponseWriter, r *http.Request) {
if s.cfg == nil {
return
}
applyCORSHeaders(w, r, s.cfg.CORSAllowedOrigins)
}
+12 -3
View File
@@ -112,6 +112,11 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
@@ -133,9 +138,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
req.UserName,
req.UserRole,
)
if req.UserID > 0 {
sess.UserID = req.UserID
}
sess.UserID = user.ID
if req.ProjectID > 0 {
sess.ProjectID = req.ProjectID
}
@@ -150,11 +153,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
var req TaskRunRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
req.UserID = user.ID
// 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例
var runRecord *projectworkflow.WorkflowRun
+1 -1
View File
@@ -444,7 +444,7 @@ func (s *Server) getOrCreateSession(
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
s.writeCORS(w, r)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With")
if r.Method == http.MethodOptions {
+15 -8
View File
@@ -145,13 +145,19 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
return
}
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
req.UserID = user.ID
// 1. 设置 SSE 响应头 + 请求追踪 ID
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
w.Header().Set("X-Request-Id", requestID)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
s.writeCORS(w, r)
w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲
flusher.Flush()
@@ -169,9 +175,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
req.UserName,
req.UserRole,
)
if req.UserID > 0 {
sess.UserID = req.UserID
}
sess.UserID = req.UserID
if req.ProjectID > 0 {
sess.ProjectID = req.ProjectID
}
@@ -308,16 +312,19 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
s.writeCORS(w, r)
w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲
flusher.Flush()
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
if req.TaskKey == "" && req.Stage == "" {
req.TaskKey = "intake"
}
if user := s.principalFromRequest(r); user != nil {
req.UserID = user.ID
}
req.UserID = user.ID
req.WorkDir = ""
runID := fmt.Sprintf("run_%d", time.Now().UnixNano())
+1 -1
View File
@@ -137,7 +137,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
s.writeCORS(w, r)
w.Header().Set("X-Accel-Buffering", "no")
flusher.Flush()