diff --git a/backend/internal/bootstrap/router.go b/backend/internal/bootstrap/router.go new file mode 100644 index 0000000..810a732 --- /dev/null +++ b/backend/internal/bootstrap/router.go @@ -0,0 +1,306 @@ +package bootstrap + +import ( + "context" + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/setup" +) + +const ( + bootstrapSessionRequestLimit = 4 * 1024 + bootstrapRequestLimit = 64 * 1024 + databaseVerificationTimeout = 20 * time.Second + bootstrapDatabaseDisplaySQLite = "SQLite" +) + +type bootstrapDatabaseVerifier interface { + Verify(context.Context, DatabaseInput) (DatabaseVerificationResult, error) +} + +type bootstrapDNSVerifier interface { + Verify(context.Context, setup.DNSVerificationInput) (setup.DNSVerificationResult, error) +} + +// Router 只承载首次初始化接口,不注册正式管理接口。 +type Router struct { + sessions *SessionManager + database bootstrapDatabaseVerifier + dns bootstrapDNSVerifier +} + +type bootstrapSessionRequest struct { + Token string `json:"token"` +} + +type bootstrapDNSTestRequest struct { + Provider string `json:"provider"` + TestDomain string `json:"test_domain"` + Credentials map[string]string `json:"credentials"` +} + +// NewRouter 创建与正式管理服务完全分离的首次初始化路由。 +func NewRouter(sessions *SessionManager, database bootstrapDatabaseVerifier, dns bootstrapDNSVerifier) *Router { + return &Router{sessions: sessions, database: database, dns: dns} +} + +func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + setBootstrapSecurityHeaders(writer) + if request == nil { + writeBootstrapError(writer, http.StatusBadRequest, "请求无效") + return + } + switch request.URL.Path { + case "/api/v1/bootstrap/status": + if !requireBootstrapMethod(writer, request, http.MethodGet) { + return + } + router.status(writer) + case "/api/v1/bootstrap/session": + if !requireBootstrapMethod(writer, request, http.MethodPost) { + return + } + router.exchangeSession(writer, request) + case "/api/v1/bootstrap/database/test": + if !requireBootstrapMethod(writer, request, http.MethodPost) || !router.authenticate(writer, request) { + return + } + router.testDatabase(writer, request) + case "/api/v1/bootstrap/dns/providers": + if !requireBootstrapMethod(writer, request, http.MethodGet) || !router.authenticate(writer, request) { + return + } + router.listDNSProviders(writer) + case "/api/v1/bootstrap/dns/test": + if !requireBootstrapMethod(writer, request, http.MethodPost) || !router.authenticate(writer, request) { + return + } + router.testDNS(writer, request) + default: + writeBootstrapError(writer, http.StatusNotFound, "接口不存在") + } +} + +func (router *Router) status(writer http.ResponseWriter) { + writeBootstrapJSON(writer, http.StatusOK, map[string]any{ + "mode": "bootstrap", + "session_required": true, + "database_test_available": router != nil && router.database != nil, + "dns_test_available": router != nil && router.dns != nil, + "database_drivers": []map[string]string{ + {"id": "sqlite", "name": bootstrapDatabaseDisplaySQLite}, + {"id": "mysql", "name": "MySQL"}, + {"id": "postgres", "name": "PostgreSQL"}, + }, + "dns_provider": dnsprovider.Aliyun, + }) +} + +func (router *Router) exchangeSession(writer http.ResponseWriter, request *http.Request) { + if router == nil || router.sessions == nil { + writeBootstrapError(writer, http.StatusServiceUnavailable, "初始化会话服务尚未就绪") + return + } + var input bootstrapSessionRequest + if err := decodeBootstrapJSON(writer, request, &input, bootstrapSessionRequestLimit); err != nil { + writeBootstrapError(writer, http.StatusBadRequest, "初始化令牌请求无效") + return + } + if strings.TrimSpace(input.Token) == "" || len(input.Token) > 1024 { + writeBootstrapError(writer, http.StatusBadRequest, "初始化令牌格式无效") + return + } + session, err := router.sessions.Exchange(input.Token, request.RemoteAddr) + if err != nil { + switch { + case errors.Is(err, ErrBootstrapRateLimited): + writeBootstrapError(writer, http.StatusTooManyRequests, "初始化验证尝试过多,请稍后重试") + case errors.Is(err, ErrBootstrapBusy), errors.Is(err, ErrBootstrapSessionFull): + writeBootstrapError(writer, http.StatusServiceUnavailable, "初始化会话服务繁忙,请稍后重试") + default: + writeBootstrapError(writer, http.StatusUnauthorized, "初始化令牌无效或已经失效") + } + return + } + http.SetCookie(writer, BootstrapSessionCookie(session.Token, session.ExpiresAt)) + writeBootstrapJSON(writer, http.StatusOK, map[string]any{ + "authenticated": true, + "expires_at": session.ExpiresAt, + }) +} + +func (router *Router) testDatabase(writer http.ResponseWriter, request *http.Request) { + if router == nil || router.database == nil { + writeBootstrapError(writer, http.StatusServiceUnavailable, "数据库验证服务尚未就绪") + return + } + var input DatabaseInput + if err := decodeBootstrapJSON(writer, request, &input, bootstrapRequestLimit); err != nil { + writeBootstrapError(writer, http.StatusBadRequest, "数据库测试请求参数无效") + return + } + ctx, cancel := context.WithTimeout(request.Context(), databaseVerificationTimeout) + defer cancel() + result, err := router.database.Verify(ctx, input) + if err != nil { + writeBootstrapError(writer, http.StatusUnprocessableEntity, "数据库连接或写权限验证失败,请检查连接参数、TLS 配置和账号权限") + return + } + writeBootstrapJSON(writer, http.StatusOK, map[string]any{ + "verified": true, + "driver": result.Config.Driver, + "verification_token": result.VerificationToken, + "expires_at": result.ExpiresAt, + "verification_timeout_secs": int(databaseVerificationTimeout.Seconds()), + "message": "数据库连接和写权限验证通过。", + }) +} + +func (router *Router) listDNSProviders(writer http.ResponseWriter) { + descriptor, err := dnsprovider.GetDescriptor(dnsprovider.Aliyun) + if err != nil { + writeBootstrapError(writer, http.StatusServiceUnavailable, "阿里云 DNS 配置尚未就绪") + return + } + writeBootstrapJSON(writer, http.StatusOK, map[string]any{"items": []dnsprovider.Descriptor{descriptor}}) +} + +func (router *Router) testDNS(writer http.ResponseWriter, request *http.Request) { + if router == nil || router.dns == nil { + writeBootstrapError(writer, http.StatusServiceUnavailable, "DNS 在线验证服务尚未就绪") + return + } + var input bootstrapDNSTestRequest + if err := decodeBootstrapJSON(writer, request, &input, bootstrapRequestLimit); err != nil { + writeBootstrapError(writer, http.StatusBadRequest, "DNS 测试请求参数无效") + return + } + if !strings.EqualFold(strings.TrimSpace(input.Provider), dnsprovider.Aliyun) { + writeBootstrapError(writer, http.StatusBadRequest, "首次初始化仅支持阿里云 DNS") + return + } + timeout := dnsprovider.PermissionVerificationTimeoutFor(dnsprovider.Aliyun) + ctx, cancel := context.WithTimeout(request.Context(), timeout) + defer cancel() + result, err := router.dns.Verify(ctx, setup.DNSVerificationInput{ + ProviderID: dnsprovider.Aliyun, + TestDomain: input.TestDomain, + Credentials: input.Credentials, + }) + if err != nil { + report := redactDNSReport(result.TestReport, input.Credentials) + var verificationErr *setup.DNSVerificationError + if errors.As(err, &verificationErr) { + report = redactDNSReport(verificationErr.Report, input.Credentials) + } + writeBootstrapJSON(writer, http.StatusUnprocessableEntity, map[string]any{ + "error": "阿里云 DNS 在线验证失败,请根据验证步骤检查凭据、域名和 RAM 权限", + "test_report": report, + "verification_timeout_secs": int(timeout.Seconds()), + }) + return + } + writeBootstrapJSON(writer, http.StatusOK, map[string]any{ + "verified": true, + "verification_token": result.VerificationToken, + "expires_at": result.ExpiresAt, + "test_report": redactDNSReport(result.TestReport, input.Credentials), + "verification_timeout_secs": int(timeout.Seconds()), + "message": "阿里云 DNS 创建、查询和清理验证通过。", + }) +} + +func redactDNSReport(report setup.DNSVerificationReport, credentials map[string]string) setup.DNSVerificationReport { + redacted := report + redacted.RecordName = redactCredentialValues(redacted.RecordName, credentials) + redacted.Stages = append([]setup.DNSVerificationStage(nil), report.Stages...) + for index := range redacted.Stages { + redacted.Stages[index].Name = redactCredentialValues(redacted.Stages[index].Name, credentials) + redacted.Stages[index].Status = redactCredentialValues(redacted.Stages[index].Status, credentials) + redacted.Stages[index].Message = redactCredentialValues(redacted.Stages[index].Message, credentials) + } + return redacted +} + +func redactCredentialValues(value string, credentials map[string]string) string { + for _, credential := range credentials { + credential = strings.TrimSpace(credential) + if credential != "" { + value = strings.ReplaceAll(value, credential, "[已隐藏]") + } + } + return value +} + +func (router *Router) authenticate(writer http.ResponseWriter, request *http.Request) bool { + if router == nil || router.sessions == nil { + writeBootstrapError(writer, http.StatusServiceUnavailable, "初始化会话服务尚未就绪") + return false + } + cookie, err := request.Cookie(SessionCookieName) + if err != nil || !router.sessions.Authenticate(cookie.Value) { + http.SetCookie(writer, ClearBootstrapSessionCookie()) + writeBootstrapError(writer, http.StatusUnauthorized, "初始化会话无效或已经过期") + return false + } + return true +} + +func decodeBootstrapJSON(writer http.ResponseWriter, request *http.Request, target any, limit int64) error { + if request == nil || request.Body == nil { + return errors.New("请求体为空") + } + contentType := strings.TrimSpace(request.Header.Get("Content-Type")) + if contentType != "" { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "application/json" { + return errors.New("请求体必须使用 application/json") + } + } + request.Body = http.MaxBytesReader(writer, request.Body, limit) + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return errors.New("请求体包含多段 JSON 数据") + } + return err + } + return nil +} + +func requireBootstrapMethod(writer http.ResponseWriter, request *http.Request, method string) bool { + if request.Method == method { + return true + } + writer.Header().Set("Allow", method) + writeBootstrapError(writer, http.StatusMethodNotAllowed, "请求方法不受支持") + return false +} + +func setBootstrapSecurityHeaders(writer http.ResponseWriter) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Referrer-Policy", "no-referrer") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("X-Frame-Options", "DENY") +} + +func writeBootstrapError(writer http.ResponseWriter, status int, message string) { + writeBootstrapJSON(writer, status, map[string]string{"error": message}) +} + +func writeBootstrapJSON(writer http.ResponseWriter, status int, value any) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(value) +} diff --git a/backend/internal/bootstrap/router_test.go b/backend/internal/bootstrap/router_test.go new file mode 100644 index 0000000..717d09b --- /dev/null +++ b/backend/internal/bootstrap/router_test.go @@ -0,0 +1,340 @@ +package bootstrap + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/dnsverification" + "git.pcux.cn/xuans/Scert/backend/internal/setup" +) + +type fakeBootstrapDatabaseVerifier struct { + result DatabaseVerificationResult + err error + calls int +} + +func (verifier *fakeBootstrapDatabaseVerifier) Verify(_ context.Context, _ DatabaseInput) (DatabaseVerificationResult, error) { + verifier.calls++ + return verifier.result, verifier.err +} + +type fakeBootstrapDNSVerifier struct { + result setup.DNSVerificationResult + err error + calls int + input setup.DNSVerificationInput +} + +func (verifier *fakeBootstrapDNSVerifier) Verify(_ context.Context, input setup.DNSVerificationInput) (setup.DNSVerificationResult, error) { + verifier.calls++ + verifier.input = input + return verifier.result, verifier.err +} + +func TestBootstrapRouterRequiresSessionForProtectedAPIs(t *testing.T) { + sessions, _, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + database := &fakeBootstrapDatabaseVerifier{} + dns := &fakeBootstrapDNSVerifier{} + router := NewRouter(sessions, database, dns) + + for _, item := range []struct { + method string + path string + body string + }{ + {http.MethodPost, "/api/v1/bootstrap/database/test", `{"driver":"sqlite"}`}, + {http.MethodGet, "/api/v1/bootstrap/dns/providers", ""}, + {http.MethodPost, "/api/v1/bootstrap/dns/test", `{"provider":"aliyun"}`}, + } { + response := performBootstrapRequest(router, item.method, item.path, item.body, nil, "192.0.2.10:50000") + if response.Code != http.StatusUnauthorized { + t.Fatalf("未认证请求 %s 返回状态不正确:%d body=%s", item.path, response.Code, response.Body.String()) + } + assertBootstrapSecurityHeaders(t, response) + } + if database.calls != 0 || dns.calls != 0 { + t.Fatal("未认证请求调用了底层验证服务") + } +} + +func TestBootstrapRouterExchangesTokenOnceAndSetsSecureCookie(t *testing.T) { + sessions, token, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + router := NewRouter(sessions, nil, nil) + body := fmt.Sprintf(`{"token":%q}`, token) + first := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/session", body, map[string]string{ + "Content-Type": "application/json", + "X-Forwarded-For": "203.0.113.99", + }, "192.0.2.20:51000") + if first.Code != http.StatusOK { + t.Fatalf("兑换初始化令牌失败:%d body=%s", first.Code, first.Body.String()) + } + cookies := first.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("初始化会话 Cookie 数量不正确:%d", len(cookies)) + } + cookie := cookies[0] + if cookie.Name != SessionCookieName || cookie.Value == "" || cookie.Path != "/api/v1/bootstrap" || + !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("初始化会话 Cookie 安全属性不完整:%+v", cookie) + } + if strings.Contains(first.Body.String(), token) || strings.Contains(first.Body.String(), cookie.Value) { + t.Fatal("初始化令牌或 Cookie 值被写入响应正文") + } + second := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/session", body, map[string]string{ + "Content-Type": "application/json", + }, "192.0.2.20:51001") + if second.Code != http.StatusUnauthorized { + t.Fatalf("一次性令牌被重复兑换:%d body=%s", second.Code, second.Body.String()) + } +} + +func TestBootstrapRouterIgnoresForwardedHeadersForRateLimit(t *testing.T) { + sessions, _, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + router := NewRouter(sessions, nil, nil) + for index := 0; index < bootstrapSourceAttemptLimit; index++ { + response := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/session", `{"token":"wrong"}`, map[string]string{ + "Content-Type": "application/json", + "X-Forwarded-For": fmt.Sprintf("203.0.113.%d", index+1), + "X-Real-IP": fmt.Sprintf("198.51.100.%d", index+1), + }, "192.0.2.44:52000") + if response.Code != http.StatusUnauthorized { + t.Fatalf("第 %d 次错误令牌请求返回异常:%d", index+1, response.Code) + } + } + limited := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/session", `{"token":"wrong"}`, map[string]string{ + "Content-Type": "application/json", + "X-Forwarded-For": "203.0.113.250", + }, "192.0.2.44:52001") + if limited.Code != http.StatusTooManyRequests { + t.Fatalf("伪造转发头绕过来源限流:%d body=%s", limited.Code, limited.Body.String()) + } +} + +func TestBootstrapRouterRejectsInvalidJSONBodies(t *testing.T) { + tests := []struct { + name string + body string + mediaType string + wantStatus int + }{ + {name: "未知字段", body: `{"token":"x","extra":true}`, mediaType: "application/json", wantStatus: http.StatusBadRequest}, + {name: "多段JSON", body: `{"token":"x"}{"token":"y"}`, mediaType: "application/json", wantStatus: http.StatusBadRequest}, + {name: "错误类型", body: `{"token":"x"}`, mediaType: "text/plain", wantStatus: http.StatusBadRequest}, + {name: "超大请求", body: `{"token":"` + strings.Repeat("x", bootstrapSessionRequestLimit) + `"}`, mediaType: "application/json", wantStatus: http.StatusBadRequest}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + sessions, _, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + response := performBootstrapRequest(NewRouter(sessions, nil, nil), http.MethodPost, "/api/v1/bootstrap/session", item.body, map[string]string{ + "Content-Type": item.mediaType, + }, "192.0.2.50:53000") + if response.Code != item.wantStatus { + t.Fatalf("状态不正确:%d body=%s", response.Code, response.Body.String()) + } + }) + } +} + +func TestBootstrapRouterOnlyListsAndAcceptsAliyun(t *testing.T) { + sessions, cookie := authenticatedBootstrapSession(t) + dns := &fakeBootstrapDNSVerifier{} + router := NewRouter(sessions, nil, dns) + + providers := performBootstrapRequest(router, http.MethodGet, "/api/v1/bootstrap/dns/providers", "", nil, "192.0.2.60:54000", cookie) + if providers.Code != http.StatusOK { + t.Fatalf("读取 DNS 服务商失败:%d body=%s", providers.Code, providers.Body.String()) + } + var payload struct { + Items []dnsprovider.Descriptor `json:"items"` + } + if err := json.Unmarshal(providers.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if len(payload.Items) != 1 || payload.Items[0].ID != dnsprovider.Aliyun { + t.Fatalf("首次初始化返回了非阿里云服务商:%+v", payload.Items) + } + + rejected := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/dns/test", `{ + "provider":"cloudflare", + "test_domain":"example.com", + "credentials":{"api_token":"secret"} + }`, map[string]string{"Content-Type": "application/json"}, "192.0.2.60:54001", cookie) + if rejected.Code != http.StatusBadRequest || dns.calls != 0 { + t.Fatalf("非阿里云请求未在调用验证器前拒绝:status=%d calls=%d", rejected.Code, dns.calls) + } +} + +func TestBootstrapRouterResponsesDoNotLeakDatabaseOrDNSCredentials(t *testing.T) { + sessions, cookie := authenticatedBootstrapSession(t) + database := &fakeBootstrapDatabaseVerifier{result: DatabaseVerificationResult{ + Config: config.DatabaseConfig{ + Driver: "mysql", + DSN: "scert_user:database-password@tcp(db.example.test:3306)/scert", + CredentialRef: "secret://database/private-reference", + }, + CredentialRef: "secret://database/private-reference", + VerificationToken: "database-verification-token", + ExpiresAt: time.Now().UTC().Add(time.Minute), + }} + dns := &fakeBootstrapDNSVerifier{result: setup.DNSVerificationResult{ + CredentialRef: "secret://dns/aliyun/private-reference", + VerificationToken: "dns-verification-token", + ExpiresAt: time.Now().UTC().Add(time.Minute), + TestReport: dnsverification.Report{RecordName: "_scert.example.com.", Stages: []dnsverification.Stage{ + {Name: "查询记录", Status: "completed", Message: "已完成安全验证。"}, + }}, + }} + router := NewRouter(sessions, database, dns) + + databaseResponse := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/database/test", `{ + "driver":"mysql", + "host":"db.example.test", + "database":"scert", + "username":"scert_user", + "password":"database-password", + "tls_mode":"true" + }`, map[string]string{"Content-Type": "application/json"}, "192.0.2.70:55000", cookie) + assertResponseExcludes(t, databaseResponse, "scert_user", "database-password", "secret://", "private-reference") + if databaseResponse.Code != http.StatusOK || !strings.Contains(databaseResponse.Body.String(), "database-verification-token") { + t.Fatalf("数据库验证响应不正确:%d body=%s", databaseResponse.Code, databaseResponse.Body.String()) + } + + dnsResponse := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/dns/test", `{ + "provider":"aliyun", + "test_domain":"example.com", + "credentials":{ + "access_key_id":"test-access-id", + "access_key_secret":"test-access-secret", + "security_token":"test-security-token" + } + }`, map[string]string{"Content-Type": "application/json"}, "192.0.2.70:55001", cookie) + assertResponseExcludes(t, dnsResponse, "test-access-id", "test-access-secret", "test-security-token", "secret://", "private-reference") + if dnsResponse.Code != http.StatusOK || dns.calls != 1 || dns.input.ProviderID != dnsprovider.Aliyun { + t.Fatalf("阿里云 DNS 验证响应不正确:status=%d calls=%d input=%+v body=%s", dnsResponse.Code, dns.calls, dns.input, dnsResponse.Body.String()) + } +} + +func TestBootstrapRouterRedactsSensitiveUnderlyingErrors(t *testing.T) { + sessions, cookie := authenticatedBootstrapSession(t) + databaseSecret := "database-password" + dnsSecret := "ram-access-secret" + database := &fakeBootstrapDatabaseVerifier{err: errors.New("connect failed with password " + databaseSecret)} + report := dnsverification.Report{RecordName: "_scert.example.com.", Stages: []dnsverification.Stage{{ + Name: "创建记录", Status: "failed", Message: "provider returned " + dnsSecret, + }}} + dns := &fakeBootstrapDNSVerifier{result: setup.DNSVerificationResult{TestReport: report}, err: &setup.DNSVerificationError{ + Report: report, + Err: errors.New("aliyun rejected " + dnsSecret), + }} + router := NewRouter(sessions, database, dns) + + databaseResponse := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/database/test", fmt.Sprintf(`{ + "driver":"mysql","host":"db.example.test","database":"scert", + "username":"scert_user","password":%q,"tls_mode":"true" + }`, databaseSecret), map[string]string{"Content-Type": "application/json"}, "192.0.2.80:56000", cookie) + if databaseResponse.Code != http.StatusUnprocessableEntity { + t.Fatalf("数据库错误状态不正确:%d", databaseResponse.Code) + } + assertResponseExcludes(t, databaseResponse, databaseSecret, "connect failed") + + dnsResponse := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/dns/test", fmt.Sprintf(`{ + "provider":"aliyun","test_domain":"example.com", + "credentials":{"access_key_id":"id","access_key_secret":%q} + }`, dnsSecret), map[string]string{"Content-Type": "application/json"}, "192.0.2.80:56001", cookie) + if dnsResponse.Code != http.StatusUnprocessableEntity { + t.Fatalf("DNS 错误状态不正确:%d body=%s", dnsResponse.Code, dnsResponse.Body.String()) + } + assertResponseExcludes(t, dnsResponse, dnsSecret, "aliyun rejected") +} + +func TestBootstrapRouterStatusAndUnknownAPIAreSafeJSON(t *testing.T) { + router := NewRouter(nil, nil, nil) + status := performBootstrapRequest(router, http.MethodGet, "/api/v1/bootstrap/status", "", nil, "192.0.2.90:57000") + if status.Code != http.StatusOK || status.Header().Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("Bootstrap 状态响应不正确:%d headers=%v", status.Code, status.Header()) + } + assertResponseExcludes(t, status, "token=", SessionCookieName, "bootstrap-url", "secret://") + assertBootstrapSecurityHeaders(t, status) + + notFound := performBootstrapRequest(router, http.MethodGet, "/api/v1/unknown", "", nil, "192.0.2.90:57001") + if notFound.Code != http.StatusNotFound || !strings.Contains(notFound.Header().Get("Content-Type"), "application/json") { + t.Fatalf("未知 API 未返回 JSON 404:%d headers=%v body=%s", notFound.Code, notFound.Header(), notFound.Body.String()) + } +} + +func authenticatedBootstrapSession(t *testing.T) (*SessionManager, *http.Cookie) { + t.Helper() + sessions, token, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + session, err := sessions.Exchange(token, "192.0.2.1:49000") + if err != nil { + t.Fatal(err) + } + return sessions, BootstrapSessionCookie(session.Token, session.ExpiresAt) +} + +func performBootstrapRequest( + router http.Handler, + method string, + path string, + body string, + headers map[string]string, + remoteAddr string, + cookies ...*http.Cookie, +) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, "https://bootstrap.example.test"+path, strings.NewReader(body)) + request.RemoteAddr = remoteAddr + for name, value := range headers { + request.Header.Set(name, value) + } + for _, cookie := range cookies { + request.AddCookie(cookie) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func assertBootstrapSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) { + t.Helper() + if response.Header().Get("Cache-Control") != "no-store" || + response.Header().Get("Referrer-Policy") != "no-referrer" || + response.Header().Get("X-Content-Type-Options") != "nosniff" || + response.Header().Get("X-Frame-Options") != "DENY" { + t.Fatalf("Bootstrap 安全响应头不完整:%v", response.Header()) + } +} + +func assertResponseExcludes(t *testing.T, response *httptest.ResponseRecorder, values ...string) { + t.Helper() + body := response.Body.String() + for _, value := range values { + if strings.Contains(body, value) { + t.Fatalf("响应泄露敏感内容 %q:%s", value, body) + } + } +}