门禁兼容修复

This commit is contained in:
andy committed 2026-09-05 17:36:23 +08:00
1 parent f60b416949
commit 801c0af692
18 files changed
+257 -87

No files matched your search

+16 -11
View File
@@ -41,6 +41,9 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) {
if !cfg.SuperAgent.Enabled {
return nil, fmt.Errorf("initialize chat: SuperAgent must be enabled")
}
if cfg.Chat.AllowLegacyShortToken {
log.Printf("security warning: legacy short chat auth token compatibility is enabled")
}
superAgentClient, err := superagent.NewHTTPClient(superagent.Config{
Enabled: cfg.SuperAgent.Enabled,
BaseURL: cfg.SuperAgent.BaseURL,
@@ -67,11 +70,12 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) {
return nil, fmt.Errorf("initialize chat service: %w", err)
}
chatHandler, err := handler.NewChatHandler(chatService, handler.ChatOptions{
AuthToken: cfg.Chat.AuthToken,
AllowedOrigins: cfg.Chat.AllowedOrigins,
MaxBodyBytes: cfg.Chat.MaxBodyBytes,
RunTimeout: cfg.Chat.RunTimeout,
Logger: log.Default(),
AuthToken: cfg.Chat.AuthToken,
AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken,
AllowedOrigins: cfg.Chat.AllowedOrigins,
MaxBodyBytes: cfg.Chat.MaxBodyBytes,
RunTimeout: cfg.Chat.RunTimeout,
Logger: log.Default(),
})
if err != nil {
return nil, fmt.Errorf("initialize chat handler: %w", err)
@@ -79,12 +83,13 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) {
routerOptions.Chat = chatHandler
if cfg.Chat.CompatAppID != "" {
dashScopeChatHandler, err := handler.NewDashScopeChatHandler(chatService, handler.DashScopeChatOptions{
AppID: cfg.Chat.CompatAppID,
AuthToken: cfg.Chat.AuthToken,
AllowedOrigins: cfg.Chat.AllowedOrigins,
MaxBodyBytes: cfg.Chat.MaxBodyBytes,
RunTimeout: cfg.Chat.RunTimeout,
Logger: log.Default(),
AppID: cfg.Chat.CompatAppID,
AuthToken: cfg.Chat.AuthToken,
AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken,
AllowedOrigins: cfg.Chat.AllowedOrigins,
MaxBodyBytes: cfg.Chat.MaxBodyBytes,
RunTimeout: cfg.Chat.RunTimeout,
Logger: log.Default(),
})
if err != nil {
return nil, fmt.Errorf("initialize DashScope-compatible chat handler: %w", err)
+24 -10
View File
@@ -1,8 +1,10 @@
package app
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
@@ -43,6 +45,11 @@ func TestNewKeepsExternalIntegrationsDisabledByDefault(t *testing.T) {
}
func TestNewWiresChatAPIToSuperAgent(t *testing.T) {
var applicationLogs bytes.Buffer
originalLogOutput := log.Writer()
log.SetOutput(&applicationLogs)
t.Cleanup(func() { log.SetOutput(originalLogOutput) })
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer provider-open-api-key" {
t.Errorf("provider Authorization = %q", r.Header.Get("Authorization"))
@@ -77,14 +84,15 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) {
MaxMessageBytes: 4096,
},
Chat: config.ChatConfig{
Enabled: true,
AuthToken: "0123456789abcdef0123456789abcdef",
SubjectID: "app-chat-test-subject",
CompatAppID: "fire-safety-app",
MaxBodyBytes: 4096,
RunTimeout: time.Minute,
SessionTTL: time.Minute,
MaxSessions: 10,
Enabled: true,
AuthToken: "delivered-key",
AllowLegacyShortToken: true,
SubjectID: "app-chat-test-subject",
CompatAppID: "fire-safety-app",
MaxBodyBytes: 4096,
RunTimeout: time.Minute,
SessionTTL: time.Minute,
MaxSessions: 10,
},
})
if err != nil {
@@ -93,7 +101,7 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) {
defer application.close()
request := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(`{"message":"你好"}`))
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
request.Header.Set("Authorization", "Bearer delivered-key")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "text/event-stream")
response := httptest.NewRecorder()
@@ -105,7 +113,7 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) {
}
compatRequest := httptest.NewRequest(http.MethodPost, "/api/v1/apps/fire-safety-app/completion", strings.NewReader(`{"input":{"prompt":"你好"},"parameters":{}}`))
compatRequest.Header.Set("xtoken", "0123456789abcdef0123456789abcdef")
compatRequest.Header.Set("xtoken", "delivered-key")
compatRequest.Header.Set("Content-Type", "application/json")
compatResponse := httptest.NewRecorder()
application.server.Handler.ServeHTTP(compatResponse, compatRequest)
@@ -113,4 +121,10 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) {
!strings.Contains(compatResponse.Body.String(), `"finish_reason":"stop"`) || !strings.Contains(compatResponse.Body.String(), "测试回答") {
t.Fatalf("compat chat status=%d body=%s", compatResponse.Code, compatResponse.Body.String())
}
if !strings.Contains(applicationLogs.String(), "legacy short chat auth token compatibility is enabled") {
t.Fatalf("application logs did not contain legacy compatibility warning: %s", applicationLogs.String())
}
if strings.Contains(applicationLogs.String(), "delivered-key") {
t.Fatal("application logs leaked the chat auth token")
}
}
+46 -29
View File
@@ -25,15 +25,16 @@ const (
SuperAgentProbeSubjectIDEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_SUBJECT_ID"
SuperAgentProbeTimeoutEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_TIMEOUT"
ChatEnabledEnv = "FIRE_SAFETY_CHAT_ENABLED"
ChatAuthTokenEnv = "FIRE_SAFETY_CHAT_AUTH_TOKEN"
ChatSubjectIDEnv = "FIRE_SAFETY_CHAT_SUBJECT_ID"
ChatAllowedOriginsEnv = "FIRE_SAFETY_CHAT_ALLOWED_ORIGINS"
ChatMaxBodyBytesEnv = "FIRE_SAFETY_CHAT_MAX_BODY_BYTES"
ChatRunTimeoutEnv = "FIRE_SAFETY_CHAT_RUN_TIMEOUT"
ChatSessionTTLEnv = "FIRE_SAFETY_CHAT_SESSION_TTL"
ChatMaxSessionsEnv = "FIRE_SAFETY_CHAT_MAX_SESSIONS"
ChatCompatAppIDEnv = "FIRE_SAFETY_CHAT_COMPAT_APP_ID"
ChatEnabledEnv = "FIRE_SAFETY_CHAT_ENABLED"
ChatAuthTokenEnv = "FIRE_SAFETY_CHAT_AUTH_TOKEN"
ChatAllowLegacyShortTokenEnv = "FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN"
ChatSubjectIDEnv = "FIRE_SAFETY_CHAT_SUBJECT_ID"
ChatAllowedOriginsEnv = "FIRE_SAFETY_CHAT_ALLOWED_ORIGINS"
ChatMaxBodyBytesEnv = "FIRE_SAFETY_CHAT_MAX_BODY_BYTES"
ChatRunTimeoutEnv = "FIRE_SAFETY_CHAT_RUN_TIMEOUT"
ChatSessionTTLEnv = "FIRE_SAFETY_CHAT_SESSION_TTL"
ChatMaxSessionsEnv = "FIRE_SAFETY_CHAT_MAX_SESSIONS"
ChatCompatAppIDEnv = "FIRE_SAFETY_CHAT_COMPAT_APP_ID"
MCPEnabledEnv = "FIRE_SAFETY_MCP_ENABLED"
MCPAuthTokenEnv = "FIRE_SAFETY_MCP_AUTH_TOKEN"
@@ -117,15 +118,16 @@ type SuperAgentConfig struct {
// conversation settings. The static credential is a test-stage boundary, not
// final end-user authentication.
type ChatConfig struct {
Enabled bool
AuthToken string
SubjectID string
CompatAppID string
AllowedOrigins []string
MaxBodyBytes int64
RunTimeout time.Duration
SessionTTL time.Duration
MaxSessions int
Enabled bool
AuthToken string
AllowLegacyShortToken bool
SubjectID string
CompatAppID string
AllowedOrigins []string
MaxBodyBytes int64
RunTimeout time.Duration
SessionTTL time.Duration
MaxSessions int
}
// MCPConfig contains inbound SuperAgent MCP settings.
@@ -229,6 +231,10 @@ func loadChatConfig() (ChatConfig, error) {
if err != nil {
return ChatConfig{}, err
}
allowLegacyShortToken, err := parseBool(ChatAllowLegacyShortTokenEnv, false)
if err != nil {
return ChatConfig{}, err
}
maxBodyBytes, err := parseInt64(ChatMaxBodyBytesEnv, defaultChatMaxBodyBytes)
if err != nil {
return ChatConfig{}, err
@@ -251,15 +257,16 @@ func loadChatConfig() (ChatConfig, error) {
}
cfg := ChatConfig{
Enabled: enabled,
AuthToken: strings.TrimSpace(os.Getenv(ChatAuthTokenEnv)),
SubjectID: valueOrDefault(ChatSubjectIDEnv, defaultChatSubjectID),
CompatAppID: strings.TrimSpace(os.Getenv(ChatCompatAppIDEnv)),
AllowedOrigins: allowedOrigins,
MaxBodyBytes: maxBodyBytes,
RunTimeout: runTimeout,
SessionTTL: sessionTTL,
MaxSessions: int(maxSessions),
Enabled: enabled,
AuthToken: os.Getenv(ChatAuthTokenEnv),
AllowLegacyShortToken: allowLegacyShortToken,
SubjectID: valueOrDefault(ChatSubjectIDEnv, defaultChatSubjectID),
CompatAppID: strings.TrimSpace(os.Getenv(ChatCompatAppIDEnv)),
AllowedOrigins: allowedOrigins,
MaxBodyBytes: maxBodyBytes,
RunTimeout: runTimeout,
SessionTTL: sessionTTL,
MaxSessions: int(maxSessions),
}
if cfg.MaxBodyBytes <= 0 || cfg.MaxBodyBytes > maximumChatBodyBytes {
return ChatConfig{}, fmt.Errorf("%s must be between 1 and %d", ChatMaxBodyBytesEnv, maximumChatBodyBytes)
@@ -279,12 +286,22 @@ func loadChatConfig() (ChatConfig, error) {
if cfg.CompatAppID != "" && !validChatCompatAppID(cfg.CompatAppID) {
return ChatConfig{}, fmt.Errorf("%s must contain 1 to 128 ASCII letters, digits, underscores, or hyphens", ChatCompatAppIDEnv)
}
if cfg.Enabled && (len(cfg.AuthToken) < 32 || !validSecretHeaderValue(cfg.AuthToken)) {
return ChatConfig{}, fmt.Errorf("%s must contain at least 32 printable ASCII characters", ChatAuthTokenEnv)
if cfg.Enabled && !validChatAuthToken(cfg.AuthToken, cfg.AllowLegacyShortToken) {
if cfg.AllowLegacyShortToken {
return ChatConfig{}, fmt.Errorf("%s must contain 1 to 4096 printable ASCII characters when %s is true", ChatAuthTokenEnv, ChatAllowLegacyShortTokenEnv)
}
return ChatConfig{}, fmt.Errorf("%s must contain 32 to 4096 printable ASCII characters", ChatAuthTokenEnv)
}
return cfg, nil
}
func validChatAuthToken(value string, allowLegacyShortToken bool) bool {
if !validSecretHeaderValue(value) {
return false
}
return allowLegacyShortToken || len(value) >= 32
}
func validChatCompatAppID(value string) bool {
if value == "" || len(value) > 128 {
return false
+58
View File
@@ -1,6 +1,7 @@
package config
import (
"strconv"
"strings"
"testing"
"time"
@@ -40,6 +41,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Chat.Enabled {
t.Fatal("Chat.Enabled = true, want false")
}
if cfg.Chat.AllowLegacyShortToken {
t.Fatal("Chat.AllowLegacyShortToken = true, want false")
}
if cfg.Chat.SubjectID != "fire-safety-ymd-chat-test-subject" || cfg.Chat.MaxBodyBytes != 128*1024 ||
cfg.Chat.CompatAppID != "" || cfg.Chat.RunTimeout != 10*time.Minute || cfg.Chat.SessionTTL != 30*time.Minute || cfg.Chat.MaxSessions != 1000 {
t.Fatalf("unexpected Chat defaults: %#v", cfg.Chat)
@@ -209,6 +213,7 @@ func TestLoadRejectsInvalidValues(t *testing.T) {
{name: "probe timeout syntax", key: SuperAgentProbeTimeoutEnv, value: "forever"},
{name: "probe timeout non-positive", key: SuperAgentProbeTimeoutEnv, value: "0s"},
{name: "Chat boolean", key: ChatEnabledEnv, value: "sometimes"},
{name: "Chat legacy short token boolean", key: ChatAllowLegacyShortTokenEnv, value: "sometimes"},
{name: "Chat body syntax", key: ChatMaxBodyBytesEnv, value: "large"},
{name: "Chat body non-positive", key: ChatMaxBodyBytesEnv, value: "0"},
{name: "Chat body too large", key: ChatMaxBodyBytesEnv, value: "1048577"},
@@ -300,6 +305,57 @@ func TestLoadRequiresEnabledChatSettings(t *testing.T) {
}
}
func TestLoadChatLegacyShortTokenPolicy(t *testing.T) {
configureChat := func(t *testing.T, token string, allowLegacy bool) {
t.Helper()
t.Setenv(SuperAgentEnabledEnv, "true")
t.Setenv(SuperAgentBaseURLEnv, "https://superagent.example.test")
t.Setenv(SuperAgentOpenAPIKeyEnv, "different-superagent-open-api-key")
t.Setenv(ChatEnabledEnv, "true")
t.Setenv(ChatAuthTokenEnv, token)
t.Setenv(ChatAllowLegacyShortTokenEnv, strconv.FormatBool(allowLegacy))
}
t.Run("default rejects short token", func(t *testing.T) {
clearEnvironment(t)
configureChat(t, "delivered-key", false)
if _, err := Load(); err == nil || !strings.Contains(err.Error(), ChatAuthTokenEnv) {
t.Fatalf("Load() error = %v, want short token rejection", err)
}
})
t.Run("explicit compatibility allows short token", func(t *testing.T) {
clearEnvironment(t)
configureChat(t, "delivered-key", true)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.Chat.AllowLegacyShortToken || cfg.Chat.AuthToken != "delivered-key" {
t.Fatalf("unexpected Chat config: %#v", cfg.Chat)
}
})
for _, tt := range []struct {
name string
token string
}{
{name: "empty", token: ""},
{name: "space", token: "delivered key"},
{name: "control character", token: "delivered\tkey"},
{name: "Unicode", token: "delivered密钥"},
{name: "oversized", token: strings.Repeat("a", 4097)},
} {
t.Run("compatibility rejects "+tt.name, func(t *testing.T) {
clearEnvironment(t)
configureChat(t, tt.token, true)
if _, err := Load(); err == nil || !strings.Contains(err.Error(), ChatAuthTokenEnv) {
t.Fatalf("Load() error = %v, want unsafe token rejection", err)
}
})
}
}
func TestLoadRequiresEnabledMCPSettings(t *testing.T) {
validToken := "0123456789abcdef0123456789abcdef"
validDSN := "postgresql://fire:secret@127.0.0.1:5432/fire_safety?sslmode=disable"
@@ -318,6 +374,7 @@ func TestLoadRequiresEnabledMCPSettings(t *testing.T) {
{
name: "short token",
configure: func(t *testing.T) {
t.Setenv(ChatAllowLegacyShortTokenEnv, "true")
t.Setenv(MCPAuthTokenEnv, "short-token")
t.Setenv(MCPAllowedTownsEnv, "莒格庄镇")
},
@@ -465,6 +522,7 @@ func clearEnvironment(t *testing.T) {
SuperAgentProbeTimeoutEnv,
ChatEnabledEnv,
ChatAuthTokenEnv,
ChatAllowLegacyShortTokenEnv,
ChatSubjectIDEnv,
ChatAllowedOriginsEnv,
ChatMaxBodyBytesEnv,
+10 -9
View File
@@ -32,11 +32,12 @@ type ChatUseCase interface {
// ChatOptions contains the independently authenticated user-facing transport settings.
type ChatOptions struct {
AuthToken string
AllowedOrigins []string
MaxBodyBytes int64
RunTimeout time.Duration
Logger *log.Logger
AuthToken string
AllowLegacyShortToken bool
AllowedOrigins []string
MaxBodyBytes int64
RunTimeout time.Duration
Logger *log.Logger
}
// ChatHandler exposes a bounded SSE chat API without exposing provider credentials or sessions.
@@ -54,8 +55,8 @@ func NewChatHandler(chat ChatUseCase, options ChatOptions) (*ChatHandler, error)
if chat == nil {
return nil, errors.New("chat service is required")
}
if !validChatToken(options.AuthToken) {
return nil, errors.New("chat auth token must contain at least 32 printable ASCII characters")
if !validChatToken(options.AuthToken, options.AllowLegacyShortToken) {
return nil, errors.New("chat auth token is outside the configured printable ASCII length policy")
}
if options.MaxBodyBytes <= 0 || options.MaxBodyBytes > maximumChatBodyBytes {
return nil, errors.New("chat max body bytes is outside the supported range")
@@ -389,8 +390,8 @@ func publicChatError(err error) (int, string, string) {
}
}
func validChatToken(value string) bool {
if len(value) < 32 || len(value) > 4096 {
func validChatToken(value string, allowLegacyShortToken bool) bool {
if value == "" || len(value) > 4096 || (!allowLegacyShortToken && len(value) < 32) {
return false
}
for _, character := range value {
+16
View File
@@ -19,6 +19,10 @@ const testChatToken = "abcdef0123456789abcdef0123456789"
func TestNewChatHandlerRejectsUnsafeOptions(t *testing.T) {
tests := []ChatOptions{
{AuthToken: "short", MaxBodyBytes: 1, RunTimeout: time.Second},
{AuthToken: "", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AuthToken: "delivered\tkey", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AuthToken: "delivered密钥", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AuthToken: strings.Repeat("a", 4097), AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AuthToken: testChatToken, MaxBodyBytes: maximumChatBodyBytes + 1, RunTimeout: time.Second},
{AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: maximumChatRunTime + time.Second},
{AuthToken: testChatToken, AllowedOrigins: []string{"*"}, MaxBodyBytes: 1, RunTimeout: time.Second},
@@ -31,6 +35,18 @@ func TestNewChatHandlerRejectsUnsafeOptions(t *testing.T) {
}
}
func TestNewChatHandlerAllowsExplicitLegacyShortToken(t *testing.T) {
_, err := NewChatHandler(&fakeChatUseCase{}, ChatOptions{
AuthToken: "delivered-key",
AllowLegacyShortToken: true,
MaxBodyBytes: 1,
RunTimeout: time.Second,
})
if err != nil {
t.Fatalf("NewChatHandler() error = %v", err)
}
}
func TestChatTransportGuardsRunBeforeService(t *testing.T) {
tests := []struct {
name string
+9 -8
View File
@@ -20,12 +20,13 @@ import (
// transport. Its token authenticates callers of this service and is never a
// provider credential.
type DashScopeChatOptions struct {
AppID string
AuthToken string
AllowedOrigins []string
MaxBodyBytes int64
RunTimeout time.Duration
Logger *log.Logger
AppID string
AuthToken string
AllowLegacyShortToken bool
AllowedOrigins []string
MaxBodyBytes int64
RunTimeout time.Duration
Logger *log.Logger
}
// DashScopeChatHandler adapts the provider-neutral ChatUseCase to the narrow
@@ -48,8 +49,8 @@ func NewDashScopeChatHandler(chat ChatUseCase, options DashScopeChatOptions) (*D
if !validDashScopeAppID(options.AppID) {
return nil, errors.New("DashScope-compatible app ID is invalid")
}
if !validChatToken(options.AuthToken) {
return nil, errors.New("DashScope-compatible auth token must contain at least 32 printable ASCII characters")
if !validChatToken(options.AuthToken, options.AllowLegacyShortToken) {
return nil, errors.New("DashScope-compatible auth token is outside the configured printable ASCII length policy")
}
if options.MaxBodyBytes <= 0 || options.MaxBodyBytes > maximumChatBodyBytes {
return nil, errors.New("DashScope-compatible max body bytes is outside the supported range")
+17
View File
@@ -22,6 +22,10 @@ func TestNewDashScopeChatHandlerRejectsUnsafeOptions(t *testing.T) {
{AppID: "app/other", AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: "app.other", AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: "short", MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: "", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: "delivered\tkey", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: "delivered密钥", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: strings.Repeat("a", 4097), AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: testChatToken, MaxBodyBytes: maximumChatBodyBytes + 1, RunTimeout: time.Second},
{AppID: testDashScopeAppID, AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: maximumChatRunTime + time.Second},
{AppID: testDashScopeAppID, AuthToken: testChatToken, AllowedOrigins: []string{"*"}, MaxBodyBytes: 1, RunTimeout: time.Second},
@@ -38,6 +42,19 @@ func TestNewDashScopeChatHandlerRejectsUnsafeOptions(t *testing.T) {
}
}
func TestNewDashScopeChatHandlerAllowsExplicitLegacyShortToken(t *testing.T) {
_, err := NewDashScopeChatHandler(&fakeChatUseCase{}, DashScopeChatOptions{
AppID: testDashScopeAppID,
AuthToken: "delivered-key",
AllowLegacyShortToken: true,
MaxBodyBytes: 1,
RunTimeout: time.Second,
})
if err != nil {
t.Fatalf("NewDashScopeChatHandler() error = %v", err)
}
}
func TestDashScopeChatStreamsCompatibleResultAfterStrictSuccess(t *testing.T) {
turn := &fakeChatTurn{
conversationID: "conv_0123456789abcdef01234567",