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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user