接入首次初始化完成接口并严格校验请求
This commit is contained in:
parent
5f3d3a0a22
commit
8615296565
@ -1,6 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@ -18,6 +19,7 @@ const (
|
||||
bootstrapSessionRequestLimit = 4 * 1024
|
||||
bootstrapRequestLimit = 64 * 1024
|
||||
databaseVerificationTimeout = 20 * time.Second
|
||||
bootstrapCompletionTimeout = 12 * time.Minute
|
||||
bootstrapDatabaseDisplaySQLite = "SQLite"
|
||||
)
|
||||
|
||||
@ -29,11 +31,16 @@ type bootstrapDNSVerifier interface {
|
||||
Verify(context.Context, setup.DNSVerificationInput) (setup.DNSVerificationResult, error)
|
||||
}
|
||||
|
||||
type bootstrapCompleter interface {
|
||||
Complete(context.Context, CompleteRequest) (CompleteResult, error)
|
||||
}
|
||||
|
||||
// Router 只承载首次初始化接口,不注册正式管理接口。
|
||||
type Router struct {
|
||||
sessions *SessionManager
|
||||
database bootstrapDatabaseVerifier
|
||||
dns bootstrapDNSVerifier
|
||||
sessions *SessionManager
|
||||
database bootstrapDatabaseVerifier
|
||||
dns bootstrapDNSVerifier
|
||||
completer bootstrapCompleter
|
||||
}
|
||||
|
||||
type bootstrapSessionRequest struct {
|
||||
@ -47,8 +54,8 @@ type bootstrapDNSTestRequest struct {
|
||||
}
|
||||
|
||||
// NewRouter 创建与正式管理服务完全分离的首次初始化路由。
|
||||
func NewRouter(sessions *SessionManager, database bootstrapDatabaseVerifier, dns bootstrapDNSVerifier) *Router {
|
||||
return &Router{sessions: sessions, database: database, dns: dns}
|
||||
func NewRouter(sessions *SessionManager, database bootstrapDatabaseVerifier, dns bootstrapDNSVerifier, completer bootstrapCompleter) *Router {
|
||||
return &Router{sessions: sessions, database: database, dns: dns, completer: completer}
|
||||
}
|
||||
|
||||
func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
@ -83,6 +90,11 @@ func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Reques
|
||||
return
|
||||
}
|
||||
router.testDNS(writer, request)
|
||||
case "/api/v1/bootstrap/complete":
|
||||
if !requireBootstrapMethod(writer, request, http.MethodPost) || !router.authenticate(writer, request) {
|
||||
return
|
||||
}
|
||||
router.complete(writer, request)
|
||||
default:
|
||||
writeBootstrapError(writer, http.StatusNotFound, "接口不存在")
|
||||
}
|
||||
@ -94,6 +106,7 @@ func (router *Router) status(writer http.ResponseWriter) {
|
||||
"session_required": true,
|
||||
"database_test_available": router != nil && router.database != nil,
|
||||
"dns_test_available": router != nil && router.dns != nil,
|
||||
"complete_available": router != nil && router.completer != nil,
|
||||
"database_drivers": []map[string]string{
|
||||
{"id": "sqlite", "name": bootstrapDatabaseDisplaySQLite},
|
||||
{"id": "mysql", "name": "MySQL"},
|
||||
@ -217,6 +230,56 @@ func (router *Router) testDNS(writer http.ResponseWriter, request *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
func (router *Router) complete(writer http.ResponseWriter, request *http.Request) {
|
||||
if router == nil || router.completer == nil {
|
||||
writeBootstrapError(writer, http.StatusServiceUnavailable, "初始化提交服务尚未就绪")
|
||||
return
|
||||
}
|
||||
var input CompleteRequest
|
||||
if err := decodeBootstrapJSON(writer, request, &input, bootstrapRequestLimit); err != nil {
|
||||
writeBootstrapError(writer, http.StatusBadRequest, "初始化提交请求参数无效")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(request.Context(), bootstrapCompletionTimeout)
|
||||
defer cancel()
|
||||
result, err := router.completer.Complete(ctx, input)
|
||||
if err != nil {
|
||||
writeBootstrapCompletionError(writer, err)
|
||||
return
|
||||
}
|
||||
if !result.Completed || strings.TrimSpace(result.ManagementURL) == "" {
|
||||
writeBootstrapError(writer, http.StatusInternalServerError, "初始化提交结果无效,请检查服务状态")
|
||||
return
|
||||
}
|
||||
router.sessions.RevokeAll()
|
||||
http.SetCookie(writer, ClearBootstrapSessionCookie())
|
||||
writeBootstrapJSON(writer, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func writeBootstrapCompletionError(writer http.ResponseWriter, err error) {
|
||||
var completionErr *CompleteError
|
||||
switch {
|
||||
case errors.Is(err, ErrCompleteBusy):
|
||||
writeBootstrapError(writer, http.StatusConflict, "初始化正在执行,请等待当前请求完成")
|
||||
case errors.Is(err, ErrCompleteDone):
|
||||
writeBootstrapError(writer, http.StatusConflict, "初始化已经完成,请使用正式管理入口")
|
||||
case errors.As(err, &completionErr):
|
||||
// 不转发底层错误或可注入的说明,防止密码、凭据引用及验证令牌泄露。
|
||||
switch completionErr.Kind {
|
||||
case CompleteErrorInput:
|
||||
writeBootstrapError(writer, http.StatusBadRequest, "初始化参数无效,请检查管理员、管理入口和服务配置")
|
||||
case CompleteErrorVerification:
|
||||
writeBootstrapError(writer, http.StatusUnprocessableEntity, "数据库或 DNS 验证结果无效、过期或不匹配,请重新验证")
|
||||
case CompleteErrorDependency:
|
||||
writeBootstrapError(writer, http.StatusBadGateway, "初始化依赖操作失败,请检查数据库、证书签发和防火墙状态后重新验证")
|
||||
default:
|
||||
writeBootstrapError(writer, http.StatusInternalServerError, "初始化提交失败,请检查服务器状态后重新验证")
|
||||
}
|
||||
default:
|
||||
writeBootstrapError(writer, http.StatusInternalServerError, "初始化提交失败,请检查服务器状态后重新验证")
|
||||
}
|
||||
}
|
||||
|
||||
func redactDNSReport(report setup.DNSVerificationReport, credentials map[string]string) setup.DNSVerificationReport {
|
||||
redacted := report
|
||||
redacted.RecordName = redactCredentialValues(redacted.RecordName, credentials)
|
||||
@ -265,7 +328,15 @@ func decodeBootstrapJSON(writer http.ResponseWriter, request *http.Request, targ
|
||||
}
|
||||
}
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, limit)
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
data, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 || data[0] != '{' {
|
||||
return errors.New("请求体必须是 JSON 对象")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
|
||||
168
backend/internal/bootstrap/router_complete_test.go
Normal file
168
backend/internal/bootstrap/router_complete_test.go
Normal file
@ -0,0 +1,168 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
type fakeBootstrapCompleter struct {
|
||||
result CompleteResult
|
||||
err error
|
||||
calls int
|
||||
input CompleteRequest
|
||||
}
|
||||
|
||||
func (completer *fakeBootstrapCompleter) Complete(ctx context.Context, input CompleteRequest) (CompleteResult, error) {
|
||||
completer.calls++
|
||||
completer.input = input
|
||||
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > bootstrapCompletionTimeout {
|
||||
return CompleteResult{}, errors.New("missing completion deadline")
|
||||
}
|
||||
return completer.result, completer.err
|
||||
}
|
||||
|
||||
func TestBootstrapCompleteRequiresSessionAndPost(t *testing.T) {
|
||||
sessions, cookie := authenticatedBootstrapSession(t)
|
||||
completer := &fakeBootstrapCompleter{}
|
||||
router := NewRouter(sessions, nil, nil, completer)
|
||||
for _, item := range []struct {
|
||||
method string
|
||||
cookie *http.Cookie
|
||||
status int
|
||||
}{
|
||||
{http.MethodPost, nil, http.StatusUnauthorized},
|
||||
{http.MethodPost, &http.Cookie{Name: SessionCookieName, Value: "invalid"}, http.StatusUnauthorized},
|
||||
{http.MethodGet, cookie, http.StatusMethodNotAllowed},
|
||||
{http.MethodPut, cookie, http.StatusMethodNotAllowed},
|
||||
} {
|
||||
var cookies []*http.Cookie
|
||||
if item.cookie != nil {
|
||||
cookies = append(cookies, item.cookie)
|
||||
}
|
||||
response := performBootstrapRequest(router, item.method, "/api/v1/bootstrap/complete", `{}`, nil, "192.0.2.1:5000", cookies...)
|
||||
if response.Code != item.status || completer.calls != 0 {
|
||||
t.Fatalf("非法请求进入提交服务:状态=%d,调用=%d", response.Code, completer.calls)
|
||||
}
|
||||
if item.status == http.StatusMethodNotAllowed && response.Header().Get("Allow") != http.MethodPost {
|
||||
t.Fatal("方法错误应明确允许 POST")
|
||||
}
|
||||
assertBootstrapSecurityHeaders(t, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapCompleteRejectsInvalidBodiesBeforeSideEffects(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
name, body, contentType string
|
||||
}{
|
||||
{"unknown", `{"extra":true}`, "application/json"},
|
||||
{"trailing", `{} {}`, "application/json"},
|
||||
{"trailing_null", `{} null`, "application/json"},
|
||||
{"null", `null`, "application/json"},
|
||||
{"array", `[]`, "application/json"},
|
||||
{"empty", ``, "application/json"},
|
||||
{"wrong_type", `{"server_port":"8443"}`, "application/json"},
|
||||
{"oversized", `{"admin_password":"` + strings.Repeat("x", bootstrapRequestLimit) + `"}`, "application/json"},
|
||||
{"media_type", `{}`, "text/plain"},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
sessions, cookie := authenticatedBootstrapSession(t)
|
||||
completer := &fakeBootstrapCompleter{}
|
||||
response := performBootstrapRequest(NewRouter(sessions, nil, nil, completer), http.MethodPost,
|
||||
"/api/v1/bootstrap/complete", item.body, map[string]string{"Content-Type": item.contentType}, "192.0.2.1:5000", cookie)
|
||||
if response.Code != http.StatusBadRequest || completer.calls != 0 {
|
||||
t.Fatalf("无效正文未提前拒绝:状态=%d,调用=%d", response.Code, completer.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapCompleteMapsErrorsWithoutLeakingSecrets(t *testing.T) {
|
||||
secret := "sensitive-password-token-reference"
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
}{
|
||||
{"busy", ErrCompleteBusy, http.StatusConflict},
|
||||
{"done", ErrCompleteDone, http.StatusConflict},
|
||||
{"input", completeError(CompleteErrorInput, secret, errors.New(secret)), http.StatusBadRequest},
|
||||
{"verification", completeError(CompleteErrorVerification, secret, errors.New(secret)), http.StatusUnprocessableEntity},
|
||||
{"dependency", completeError(CompleteErrorDependency, secret, errors.New(secret)), http.StatusBadGateway},
|
||||
{"internal", completeError(CompleteErrorInternal, secret, errors.New(secret)), http.StatusInternalServerError},
|
||||
{"unknown", errors.New(secret), http.StatusInternalServerError},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
sessions, cookie := authenticatedBootstrapSession(t)
|
||||
completer := &fakeBootstrapCompleter{err: item.err}
|
||||
response := performBootstrapRequest(NewRouter(sessions, nil, nil, completer), http.MethodPost,
|
||||
"/api/v1/bootstrap/complete", `{}`, nil, "192.0.2.1:5000", cookie)
|
||||
if response.Code != item.status || completer.calls != 1 {
|
||||
t.Fatalf("错误映射不正确:%d,%s", response.Code, response.Body.String())
|
||||
}
|
||||
assertResponseExcludes(t, response, secret)
|
||||
if !sessions.Authenticate(cookie.Value) {
|
||||
t.Fatal("提交失败不能撤销可供重新验证的初始化会话")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapCompleteUnavailableAndInvalidResult(t *testing.T) {
|
||||
for _, available := range []bool{false, true} {
|
||||
sessions, cookie := authenticatedBootstrapSession(t)
|
||||
var completer bootstrapCompleter
|
||||
status := http.StatusServiceUnavailable
|
||||
if available {
|
||||
completer = &fakeBootstrapCompleter{}
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
response := performBootstrapRequest(NewRouter(sessions, nil, nil, completer), http.MethodPost,
|
||||
"/api/v1/bootstrap/complete", `{}`, nil, "192.0.2.1:5000", cookie)
|
||||
if response.Code != status || !sessions.Authenticate(cookie.Value) {
|
||||
t.Fatalf("未提交状态不应误报成功或撤销会话:%d", response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapCompleteCommitsAndClearsCookie(t *testing.T) {
|
||||
fixture := newCompletionFixture(t, "admin.example.test")
|
||||
request := fixture.request("admin.example.test")
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := NewRouter(fixture.sessions, fixture.database, fixture.dns, fixture.completer(nil))
|
||||
cookie := BootstrapSessionCookie(fixture.sessionCookie, time.Now().Add(time.Hour))
|
||||
response := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/complete", string(body),
|
||||
map[string]string{"Content-Type": "application/json"}, "192.0.2.1:5000", cookie)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("初始化 HTTP 提交失败:%d,%s", response.Code, response.Body.String())
|
||||
}
|
||||
var result CompleteResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || !result.Completed || result.ManagementURL != "https://admin.example.test:8443/" {
|
||||
t.Fatalf("管理入口响应不正确:%+v,%v", result, err)
|
||||
}
|
||||
assertResponseExcludes(t, response, request.AdminPassword, request.DatabaseVerificationToken, request.DNSVerificationToken, "secret://", "test-secret")
|
||||
cookies := response.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != SessionCookieName || cookies[0].Value != "" || cookies[0].MaxAge != -1 ||
|
||||
!cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].Path != "/api/v1/bootstrap" || cookies[0].SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("初始化成功未安全清除 Cookie:%+v", cookies)
|
||||
}
|
||||
stored, err := config.Load(fixture.configPath)
|
||||
if err != nil || !stored.SetupReady || !fixture.store.has(fixture.dnsRef) {
|
||||
t.Fatalf("HTTP 提交未保留完整正式配置和凭据:%v", err)
|
||||
}
|
||||
for _, path := range []string{"/api/v1/bootstrap/complete", "/api/v1/bootstrap/database/test", "/api/v1/bootstrap/dns/test"} {
|
||||
replayed := performBootstrapRequest(router, http.MethodPost, path, `{}`, nil, "192.0.2.1:5000", cookie)
|
||||
if replayed.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("提交后旧会话仍可调用 %s:%d", path, replayed.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,7 @@ func TestBootstrapRouterRequiresSessionForProtectedAPIs(t *testing.T) {
|
||||
}
|
||||
database := &fakeBootstrapDatabaseVerifier{}
|
||||
dns := &fakeBootstrapDNSVerifier{}
|
||||
router := NewRouter(sessions, database, dns)
|
||||
router := NewRouter(sessions, database, dns, nil)
|
||||
|
||||
for _, item := range []struct {
|
||||
method string
|
||||
@ -58,6 +58,7 @@ func TestBootstrapRouterRequiresSessionForProtectedAPIs(t *testing.T) {
|
||||
{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"}`},
|
||||
{http.MethodPost, "/api/v1/bootstrap/complete", `{}`},
|
||||
} {
|
||||
response := performBootstrapRequest(router, item.method, item.path, item.body, nil, "192.0.2.10:50000")
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
@ -75,7 +76,7 @@ func TestBootstrapRouterExchangesTokenOnceAndSetsSecureCookie(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := NewRouter(sessions, nil, nil)
|
||||
router := NewRouter(sessions, nil, 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",
|
||||
@ -109,7 +110,7 @@ func TestBootstrapRouterIgnoresForwardedHeadersForRateLimit(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := NewRouter(sessions, nil, nil)
|
||||
router := NewRouter(sessions, nil, 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",
|
||||
@ -147,7 +148,7 @@ func TestBootstrapRouterRejectsInvalidJSONBodies(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := performBootstrapRequest(NewRouter(sessions, nil, nil), http.MethodPost, "/api/v1/bootstrap/session", item.body, map[string]string{
|
||||
response := performBootstrapRequest(NewRouter(sessions, nil, 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 {
|
||||
@ -160,7 +161,7 @@ func TestBootstrapRouterRejectsInvalidJSONBodies(t *testing.T) {
|
||||
func TestBootstrapRouterOnlyListsAndAcceptsAliyun(t *testing.T) {
|
||||
sessions, cookie := authenticatedBootstrapSession(t)
|
||||
dns := &fakeBootstrapDNSVerifier{}
|
||||
router := NewRouter(sessions, nil, dns)
|
||||
router := NewRouter(sessions, nil, dns, nil)
|
||||
|
||||
providers := performBootstrapRequest(router, http.MethodGet, "/api/v1/bootstrap/dns/providers", "", nil, "192.0.2.60:54000", cookie)
|
||||
if providers.Code != http.StatusOK {
|
||||
@ -206,7 +207,7 @@ func TestBootstrapRouterResponsesDoNotLeakDatabaseOrDNSCredentials(t *testing.T)
|
||||
{Name: "查询记录", Status: "completed", Message: "已完成安全验证。"},
|
||||
}},
|
||||
}}
|
||||
router := NewRouter(sessions, database, dns)
|
||||
router := NewRouter(sessions, database, dns, nil)
|
||||
|
||||
databaseResponse := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/database/test", `{
|
||||
"driver":"mysql",
|
||||
@ -248,7 +249,7 @@ func TestBootstrapRouterRedactsSensitiveUnderlyingErrors(t *testing.T) {
|
||||
Report: report,
|
||||
Err: errors.New("aliyun rejected " + dnsSecret),
|
||||
}}
|
||||
router := NewRouter(sessions, database, dns)
|
||||
router := NewRouter(sessions, database, dns, nil)
|
||||
|
||||
databaseResponse := performBootstrapRequest(router, http.MethodPost, "/api/v1/bootstrap/database/test", fmt.Sprintf(`{
|
||||
"driver":"mysql","host":"db.example.test","database":"scert",
|
||||
@ -270,7 +271,7 @@ func TestBootstrapRouterRedactsSensitiveUnderlyingErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBootstrapRouterStatusAndUnknownAPIAreSafeJSON(t *testing.T) {
|
||||
router := NewRouter(nil, nil, nil)
|
||||
router := NewRouter(nil, 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())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user