342 lines
14 KiB
Go
342 lines
14 KiB
Go
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, nil)
|
||
|
||
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"}`},
|
||
{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 {
|
||
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, 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, 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, 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, nil)
|
||
|
||
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, nil)
|
||
|
||
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, nil)
|
||
|
||
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, 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)
|
||
}
|
||
}
|
||
}
|