实现独立首次初始化验证接口
This commit is contained in:
parent
3b3c8c7f85
commit
30749edb5e
306
backend/internal/bootstrap/router.go
Normal file
306
backend/internal/bootstrap/router.go
Normal file
@ -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)
|
||||
}
|
||||
340
backend/internal/bootstrap/router_test.go
Normal file
340
backend/internal/bootstrap/router_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user