diff --git a/backend/internal/certificate/authorization_test.go b/backend/internal/certificate/authorization_test.go index 331564a..a48c0ff 100644 --- a/backend/internal/certificate/authorization_test.go +++ b/backend/internal/certificate/authorization_test.go @@ -17,6 +17,12 @@ type scopedAuthorizationChecker struct { calls []string } +type scopedServiceCredentials struct{} + +func (scopedServiceCredentials) Get(context.Context, string) (map[string]string, error) { + return map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com,example.org", "allowed_domains": "example.com,other.example.org"}, nil +} + func (c *scopedAuthorizationChecker) VerifyDomain(_ context.Context, _ string, name string, _ map[string]string) error { c.calls = append(c.calls, name) if name == c.denied { @@ -37,7 +43,7 @@ func TestServiceRejectsUnauthorizedSANBeforeIssuerOrFileWrites(t *testing.T) { provider := &recordingProviderFactory{} repo := &serviceRepository{item: item} checker := &scopedAuthorizationChecker{denied: denied} - service := NewService(cfg, repo, serviceCredentials{}, provider, serviceAccounts{}) + service := NewService(cfg, repo, scopedServiceCredentials{}, provider, serviceAccounts{}) service.SetDNSAuthorizationChecker(checker) service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { t.Fatal("任意 SAN 权限拒绝后不得创建 ACME 签发器") @@ -62,7 +68,7 @@ func TestServiceRejectsUnauthorizedSANBeforeIssuerOrFileWrites(t *testing.T) { func TestSetupRejectsUnauthorizedDomainBeforeIssuer(t *testing.T) { cfg := certificateTestConfig(t.TempDir()) - service := NewService(cfg, nil, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) + service := NewService(cfg, nil, scopedServiceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) service.SetDNSAuthorizationChecker(&scopedAuthorizationChecker{denied: "other.example.org"}) service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { t.Fatal("初始化证书中的未授权域名必须在 ACME 请求前拒绝") @@ -86,7 +92,7 @@ func TestAliyunMissingAuthorizationCheckerBlocksIssueAndSetup(t *testing.T) { t.Fatal(err) } provider := &recordingProviderFactory{} - service := NewService(cfg, &serviceRepository{item: item}, serviceCredentials{}, provider, serviceAccounts{}) + service := NewService(cfg, &serviceRepository{item: item}, scopedServiceCredentials{}, provider, serviceAccounts{}) if _, err := service.Issue(t.Context(), item.ID); err == nil || !strings.Contains(err.Error(), "探测器未初始化") { t.Fatalf("缺失检查器必须阻止签发:%v", err) } @@ -100,3 +106,17 @@ func TestAliyunMissingAuthorizationCheckerBlocksIssueAndSetup(t *testing.T) { t.Fatal("缺失检查器时不得创建 DNS 挑战 Provider") } } + +func TestAliyunWholeOrderScopeValidationBeforeAnyCloudProbe(t *testing.T) { + cfg := certificateTestConfig(t.TempDir()) + checker := &scopedAuthorizationChecker{} + service := NewService(cfg, nil, scopedServiceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) + service.SetDNSAuthorizationChecker(checker) + _, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{ + Domains: []string{"example.com", "outside.net"}, CertificateType: "san", + ProviderID: "aliyun", CredentialRef: "secret://dns/test", Environment: "staging", + }) + if err == nil || !strings.Contains(err.Error(), "outside.net") || len(checker.calls) != 0 { + t.Fatalf("部分越界必须零云调用拒绝整单:%v %v", err, checker.calls) + } +} diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 683995c..6ffc046 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -272,6 +272,10 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq } func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, domains []string, credentials map[string]string) error { + // 必须先校验全部 SAN;后续域名越界时也不能先写入前面的域名。 + if err := dnsprovider.ValidateAliyunDomainScope(providerID, domains, credentials); err != nil { + return err + } if s == nil || s.authorization == nil { if strings.EqualFold(strings.TrimSpace(providerID), dnsprovider.Aliyun) { return errors.New("阿里云权限探测器未初始化,已阻止证书申请") diff --git a/backend/internal/cli/dns.go b/backend/internal/cli/dns.go index 69e9b53..1dab4e4 100644 --- a/backend/internal/cli/dns.go +++ b/backend/internal/cli/dns.go @@ -71,6 +71,11 @@ func newDNSPermissionCommand(verify dnsPermissionVerifier, readOnly bool) *cobra if err != nil { return fmt.Errorf("读取阿里云 DNS 凭据失败:%w", err) } + if !readOnly { + if err := dnsprovider.ValidateAliyunDomainScope(dnsprovider.Aliyun, []string{requestedDomain}, credentials); err != nil { + return err + } + } ctx, cancel := context.WithTimeout(cmd.Context(), 45*time.Second) defer cancel() report, verifyErr := verify(ctx, requestedDomain, credentials) diff --git a/backend/internal/cli/dns_test.go b/backend/internal/cli/dns_test.go index 95f0113..e0e3ee0 100644 --- a/backend/internal/cli/dns_test.go +++ b/backend/internal/cli/dns_test.go @@ -30,6 +30,19 @@ func TestDNSCheckRequiresExplicitWriteConfirmation(t *testing.T) { } } +func TestDNSCheckRejectsOutsideScopeBeforeVerifier(t *testing.T) { + prepareDNSCheckConfig(t) + command := newDNSCheckCommand(func(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error) { + t.Fatal("CLI 越界不得进入云端验证") + return dnsprovider.AuthorizationReport{}, nil + }) + command.SilenceUsage, command.SilenceErrors = true, true + command.SetArgs([]string{"--domain", "outside.net", "--confirm-dns-write"}) + if err := command.Execute(); err == nil { + t.Fatal("确认写入不等于允许扩大范围") + } +} + func TestDNSInspectDoesNotRequireWriteOrPromoteReadableToVerified(t *testing.T) { prepareDNSCheckConfig(t) command := newDNSInspectCommand(func(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error) { @@ -138,7 +151,7 @@ func prepareDNSCheckConfig(t *testing.T) config.Config { if err != nil { t.Fatal(err) } - if err := store.Put(t.Context(), cfg.DNS.CredentialRef, map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}); err != nil { + if err := store.Put(t.Context(), cfg.DNS.CredentialRef, map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com", "allowed_domains": "example.com"}); err != nil { t.Fatal(err) } return cfg diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index df98098..7b75499 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -47,10 +47,11 @@ type AuthorizationReport struct { type AliyunAuthorizationChecker struct { now func() time.Time verifyDomainFunc func(context.Context, string, map[string]string) error + recordAPI func(map[string]string) (aliyunRecordAPI, error) } func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker { - return &AliyunAuthorizationChecker{now: time.Now} + return &AliyunAuthorizationChecker{now: time.Now, recordAPI: newAliyunRecordAPI} } func (c *AliyunAuthorizationChecker) Supports(providerID string) bool { @@ -83,6 +84,19 @@ func (c *AliyunAuthorizationChecker) Inspect(ctx context.Context, requestedDomai if !domain.IsValidName(requestedDomain) { return report, fmt.Errorf("待检查托管区格式无效:%s", requestedDomain) } + scope, err := ParseAliyunScope(credentials) + if err != nil { + return report, err + } + explicit := false + for _, zone := range scope.ManagedZones { + if zone == requestedDomain { + explicit = true + } + } + if !explicit { + return report, errors.New("只允许读取显式配置的托管区,未调用阿里云 API") + } report.Domains = []AuthorizationDomain{{Domain: requestedDomain, Status: "等待读取权限检查"}} dnsClient, err := newAliyunClient(credentials) if err != nil { @@ -105,52 +119,63 @@ func (c *AliyunAuthorizationChecker) Inspect(ctx context.Context, requestedDomai return report, nil } -// Verify 从目标域名到父级托管区逐级执行最小写入和精确清理。 +// Verify 只在用户显式允许的域名及托管区中执行一次写入和精确清理。 func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (AuthorizationReport, error) { requestedDomain = normalizeAuthorizationDomain(requestedDomain) report := AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()} if !domain.IsValidName(requestedDomain) { return report, fmt.Errorf("待验证域名格式无效:%s", requestedDomain) } - for _, candidate := range authorizationZoneCandidates(requestedDomain) { - report.Domains = append(report.Domains, AuthorizationDomain{ - Domain: candidate, Status: "待验证", - }) - } - client, err := newAliyunClient(credentials) + scope, err := ParseAliyunScope(credentials) if err != nil { return report, err } - - var lastErr error - for index, candidate := range authorizationZoneCandidates(requestedDomain) { - report.Domains[index].Status = "正在证明写入权限" - recordID, probeErr := addAuthorizationRecord(ctx, client, candidate) - if probeErr != nil { - lastErr = probeErr - report.Domains[index].Status = "不可操作" - report.Domains[index].Error = probeErr.Error() - continue - } - report.Domains[index].Writable = true - report.MatchedDomain = candidate - cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout) - cleanupErr := deleteAuthorizationRecord(cleanupContext, client, recordID) - cancel() - if cleanupErr != nil { - markAuthorizationFailure(&report, candidate, fmt.Errorf("清理授权探测 TXT 记录失败:%w", cleanupErr)) - return report, cleanupErr - } - report.Domains[index].Cleanup = true - report.Domains[index].Status = "可操作" - report.Verified = true - return report, nil + zone, err := scope.Resolve(requestedDomain) + if err != nil { + return report, err } - if lastErr == nil { - lastErr = errors.New("未找到可验证的阿里云 DNS 托管根域名") + if err := ctx.Err(); err != nil { + return report, err } - report.Diagnosis = DiagnoseAliyunError(lastErr, requestedDomain) - return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w(建议:%s)", requestedDomain, lastErr, report.Diagnosis) + report.Domains = []AuthorizationDomain{{Domain: zone, Status: "待验证"}} + if c == nil || c.recordAPI == nil { + return report, errors.New("阿里云权限探测器未初始化") + } + api, err := c.recordAPI(credentials) + if err != nil { + return report, err + } + suffix, err := randomAuthorizationValue(18) + if err != nil { + return report, err + } + value, err := randomAuthorizationValue(32) + if err != nil { + return report, err + } + rr := "_scert-authorization-" + suffix + base := strings.TrimPrefix(requestedDomain, "*.") + if base != zone { + rr += "." + strings.TrimSuffix(base, "."+zone) + } + recordID, err := api.Add(ctx, zone, rr, value) + if err != nil { + markAuthorizationFailure(&report, zone, err) + report.Diagnosis = DiagnoseAliyunError(err, zone) + return report, err + } + report.Domains[0].Writable = true + report.MatchedDomain = zone + cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout) + defer cancel() + if err := api.Delete(cleanupContext, recordID); err != nil { + markAuthorizationFailure(&report, zone, fmt.Errorf("清理授权探测 TXT 记录失败:%w", err)) + return report, err + } + report.Domains[0].Cleanup = true + report.Domains[0].Status = "可操作" + report.Verified = true + return report, nil } // VerifyDomain 每次重新证明写入和清理能力,不使用跨请求缓存掩盖撤权。 @@ -171,6 +196,9 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI if _, err := ValidateCredentials(Aliyun, credentials); err != nil { return err } + if err := ValidateAliyunDomainScope(Aliyun, []string{requestedDomain}, credentials); err != nil { + return err + } if c.verifyDomainFunc != nil { return c.verifyDomainFunc(ctx, requestedDomain, credentials) } @@ -248,31 +276,6 @@ func listAliyunDomains(ctx context.Context, dnsClient *client.Client) ([]string, return uniqueStrings(result), nil } -func addAuthorizationRecord(ctx context.Context, dnsClient *client.Client, zone string) (string, error) { - suffix, err := randomAuthorizationValue(18) - if err != nil { - return "", fmt.Errorf("生成授权探测标识失败:%w", err) - } - value, err := randomAuthorizationValue(32) - if err != nil { - return "", fmt.Errorf("生成授权探测值失败:%w", err) - } - request := new(client.AddDomainRecordRequest). - SetDomainName(zone). - SetRR("_scert-authorization-" + suffix). - SetType("TXT"). - SetValue(value). - SetTTL(aliyunAuthorizationTTL) - response, err := client.AddDomainRecordWithContext(ctx, dnsClient, request, &dara.RuntimeOptions{}) - if err != nil { - return "", err - } - if response == nil || response.Body == nil || response.Body.RecordId == nil || strings.TrimSpace(*response.Body.RecordId) == "" { - return "", errors.New("阿里云未返回授权探测记录 ID") - } - return strings.TrimSpace(*response.Body.RecordId), nil -} - func deleteAuthorizationRecord(ctx context.Context, dnsClient *client.Client, recordID string) error { if strings.TrimSpace(recordID) == "" { return errors.New("授权探测记录 ID 为空") @@ -296,19 +299,6 @@ func matchManagedDomain(requested string, domains []AuthorizationDomain) string return best } -func authorizationZoneCandidates(requested string) []string { - requested = strings.TrimPrefix(normalizeAuthorizationDomain(requested), "*.") - labels := strings.Split(requested, ".") - result := make([]string, 0, len(labels)-1) - for index := 0; index < len(labels)-1; index++ { - candidate := strings.Join(labels[index:], ".") - if domain.IsValidName(candidate) { - result = append(result, candidate) - } - } - return result -} - func markAuthorizationFailure(report *AuthorizationReport, domainName string, err error) { for index := range report.Domains { if report.Domains[index].Domain == domainName { diff --git a/backend/internal/dnsprovider/aliyun_authorization_test.go b/backend/internal/dnsprovider/aliyun_authorization_test.go index b28ea85..4d35d59 100644 --- a/backend/internal/dnsprovider/aliyun_authorization_test.go +++ b/backend/internal/dnsprovider/aliyun_authorization_test.go @@ -7,19 +7,6 @@ import ( "time" ) -func TestAuthorizationZoneCandidatesPreferSpecificZones(t *testing.T) { - got := authorizationZoneCandidates("*.www.example.com.") - want := []string{"www.example.com", "example.com"} - if len(got) != len(want) { - t.Fatalf("候选托管区数量错误:got=%v want=%v", got, want) - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("候选托管区顺序错误:got=%v want=%v", got, want) - } - } -} - func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *testing.T) { now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) calls := 0 @@ -36,6 +23,8 @@ func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *t credentials := map[string]string{ "access_key_id": "test-id", "access_key_secret": "test-secret", + "managed_zones": "example.com", + "allowed_domains": "example.com,www.example.com", } if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil { @@ -67,6 +56,8 @@ func TestAliyunAuthorizationCheckerInvalidatesAfterPermissionFailure(t *testing. credentials := map[string]string{ "access_key_id": "test-id", "access_key_secret": "test-secret", + "managed_zones": "example.com", + "allowed_domains": "example.com", } if err := checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials); err != nil { @@ -91,6 +82,8 @@ func TestAliyunAuthorizationCheckerDoesNotCacheFailedAuthorization(t *testing.T) credentials := map[string]string{ "access_key_id": "test-id", "access_key_secret": "test-secret", + "managed_zones": "example.com", + "allowed_domains": "example.com", } if err := checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials); err == nil { @@ -112,6 +105,8 @@ func TestAliyunAuthorizationCheckerCacheIsSafeForConcurrentUse(t *testing.T) { credentials := map[string]string{ "access_key_id": "test-id", "access_key_secret": "test-secret", + "managed_zones": "example.com", + "allowed_domains": "example.com", } done := make(chan struct{}) for index := 0; index < 20; index++ { diff --git a/backend/internal/dnsprovider/aliyun_provider.go b/backend/internal/dnsprovider/aliyun_provider.go new file mode 100644 index 0000000..a107296 --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_provider.go @@ -0,0 +1,132 @@ +package dnsprovider + +import ( + "context" + "crypto/sha256" + "errors" + "strings" + "sync" + "time" + + "github.com/alibabacloud-go/tea/dara" + "github.com/go-acme/alidns-20150109/v5/client" + "github.com/go-acme/lego/v5/challenge/dns01" +) + +type aliyunRecordAPI interface { + Add(context.Context, string, string, string) (string, error) + Delete(context.Context, string) error +} + +type aliyunRecords struct{ client *client.Client } + +func (a aliyunRecords) Add(ctx context.Context, zone, rr, value string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, aliyunAuthorizationTimeout) + defer cancel() + request := new(client.AddDomainRecordRequest).SetDomainName(zone).SetRR(rr). + SetType("TXT").SetValue(value).SetTTL(aliyunAuthorizationTTL) + response, err := client.AddDomainRecordWithContext(ctx, a.client, request, &dara.RuntimeOptions{}) + if err != nil { + return "", err + } + if response == nil || response.Body == nil || response.Body.RecordId == nil || strings.TrimSpace(*response.Body.RecordId) == "" { + return "", errors.New("阿里云未返回新增记录 ID,状态未确认,请人工核对本次记录") + } + return strings.TrimSpace(*response.Body.RecordId), nil +} + +func (a aliyunRecords) Delete(ctx context.Context, id string) error { + return deleteAuthorizationRecord(ctx, a.client, id) +} + +func newAliyunRecordAPI(credentials map[string]string) (aliyunRecordAPI, error) { + dnsClient, err := newAliyunClient(credentials) + if err != nil { + return nil, err + } + return aliyunRecords{client: dnsClient}, nil +} + +// 不使用自动发现托管区的上游适配器,也不沿 CNAME 自动扩大写入范围。 +type scopedAliyunProvider struct { + scope AliyunScope + api aliyunRecordAPI + mu sync.Mutex + records map[[32]byte]string + challengeInfo func(context.Context, string, string) (string, string, string) +} + +func newScopedAliyunProvider(credentials map[string]string) (*scopedAliyunProvider, error) { + scope, err := ParseAliyunScope(credentials) + if err != nil { + return nil, err + } + api, err := newAliyunRecordAPI(credentials) + if err != nil { + return nil, err + } + return &scopedAliyunProvider{scope: scope, api: api, records: make(map[[32]byte]string), challengeInfo: aliyunChallengeInfo}, nil +} + +func aliyunChallengeInfo(ctx context.Context, domainName, keyAuth string) (string, string, string) { + info := dns01.GetChallengeInfo(ctx, domainName, keyAuth) + return info.FQDN, info.EffectiveFQDN, info.Value +} + +func (p *scopedAliyunProvider) Present(ctx context.Context, name, token, keyAuth string) error { + name = normalizeAuthorizationDomain(name) + zone, err := p.scope.Resolve(name) + // ACME 的通配符授权以去掉通配符的标识符进入 Provider;业务层已逐项验证 SAN。 + if err != nil && !strings.HasPrefix(name, "*.") { + zone, err = p.scope.Resolve("*." + name) + } + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + base := strings.TrimPrefix(name, "*.") + fqdn, effective, value := p.challengeInfo(ctx, base, keyAuth) + expected := "_acme-challenge." + base + if normalizeAuthorizationDomain(fqdn) != expected || normalizeAuthorizationDomain(effective) != expected { + return errors.New("DNS 挑战存在 CNAME 重定向,已拒绝自动跟随;请显式配置受限的 CNAME 委派服务商") + } + rr := strings.TrimSuffix(expected, "."+zone) + if rr == expected { + return errors.New("DNS 挑战记录不属于显式托管区") + } + key := sha256.Sum256([]byte(name + "\x00" + token + "\x00" + keyAuth)) + p.mu.Lock() + defer p.mu.Unlock() + if _, ok := p.records[key]; ok { + return errors.New("同一 DNS 挑战已有待清理记录,拒绝重复新增") + } + id, err := p.api.Add(ctx, zone, rr, value) + if err != nil { + return err + } + p.records[key] = id + return nil +} + +func (p *scopedAliyunProvider) CleanUp(ctx context.Context, name, token, keyAuth string) error { + key := sha256.Sum256([]byte(normalizeAuthorizationDomain(name) + "\x00" + token + "\x00" + keyAuth)) + p.mu.Lock() + defer p.mu.Unlock() + id, ok := p.records[key] + if !ok { + return nil + } + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout) + defer cancel() + if err := p.api.Delete(cleanupCtx, id); err != nil { + return err + } + delete(p.records, key) + return nil +} + +func (p *scopedAliyunProvider) Timeout() (time.Duration, time.Duration) { + return 120 * time.Second, 2 * time.Second +} diff --git a/backend/internal/dnsprovider/aliyun_scope.go b/backend/internal/dnsprovider/aliyun_scope.go new file mode 100644 index 0000000..0cf3541 --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_scope.go @@ -0,0 +1,116 @@ +package dnsprovider + +import ( + "errors" + "fmt" + "strings" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "golang.org/x/net/publicsuffix" +) + +// AliyunScope 是用户授权的上限,不是从 RAM 读取结果自动推导的权限。 +// 范围随凭据引用不可变地保存;修改范围必须重新在线验证并获得新的引用。 +type AliyunScope struct { + ManagedZones []string `json:"managed_zones"` + AllowedDomains []string `json:"allowed_domains"` +} + +func ParseAliyunScope(credentials map[string]string) (AliyunScope, error) { + var scope AliyunScope + var err error + scope.ManagedZones, err = parseScopeNames(credentials["managed_zones"], false) + if err != nil { + return scope, fmt.Errorf("请显式配置阿里云托管区:%w", err) + } + scope.AllowedDomains, err = parseScopeNames(credentials["allowed_domains"], true) + if err != nil { + return scope, fmt.Errorf("请显式配置允许操作的域名:%w", err) + } + for _, name := range scope.AllowedDomains { + if scope.zoneFor(strings.TrimPrefix(name, "*.")) == "" { + return AliyunScope{}, fmt.Errorf("允许域名 %s 不属于任何显式托管区", name) + } + } + return scope, nil +} + +func parseScopeNames(value string, wildcard bool) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, errors.New("旧配置或空范围不允许 DNS 操作,请补充范围并重新验证") + } + parts := strings.Split(value, ",") + if len(parts) > 128 { + return nil, errors.New("每个范围最多允许 128 项") + } + result := make([]string, 0, len(parts)) + for _, part := range parts { + name := normalizeAuthorizationDomain(part) + base := strings.TrimPrefix(name, "*.") + if !domain.IsValidName(name) || (!wildcard && name != base) { + return nil, errors.New("范围应为逗号分隔的有效域名,托管区不能含通配符") + } + if _, err := publicsuffix.EffectiveTLDPlusOne(base); err != nil { + return nil, fmt.Errorf("公共后缀不能作为操作范围:%s", name) + } + result = append(result, name) + } + return uniqueStrings(result), nil +} + +func (s AliyunScope) zoneFor(name string) string { + best := "" + for _, zone := range s.ManagedZones { + if (name == zone || strings.HasSuffix(name, "."+zone)) && len(zone) > len(best) { + best = zone + } + } + return best +} + +// Resolve 只选择最具体的显式托管区;失败后绝不回退到父区。 +// 通配授权只包含一层子域和对应通配证书,不隐含根域或多层子域。 +func (s AliyunScope) Resolve(requested string) (string, error) { + requested = normalizeAuthorizationDomain(requested) + if !domain.IsValidName(requested) { + return "", errors.New("待操作域名格式无效") + } + allowed := false + for _, name := range s.AllowedDomains { + if requested == name { + allowed = true + break + } + if strings.HasPrefix(name, "*.") && !strings.HasPrefix(requested, "*.") { + suffix := name[1:] + if strings.HasSuffix(requested, suffix) { + label := strings.TrimSuffix(requested, suffix) + allowed = label != "" && !strings.Contains(label, ".") + if allowed { + break + } + } + } + } + zone := s.zoneFor(strings.TrimPrefix(requested, "*.")) + if !allowed || zone == "" { + return "", fmt.Errorf("域名 %s 不在用户显式允许范围内,未调用阿里云 API", requested) + } + return zone, nil +} + +func ValidateAliyunDomainScope(providerID string, domains []string, credentials map[string]string) error { + if !strings.EqualFold(strings.TrimSpace(providerID), Aliyun) { + return nil + } + scope, err := ParseAliyunScope(credentials) + if err != nil { + return err + } + for _, name := range domains { + if _, err := scope.Resolve(name); err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/dnsprovider/aliyun_scope_test.go b/backend/internal/dnsprovider/aliyun_scope_test.go new file mode 100644 index 0000000..32e36c7 --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_scope_test.go @@ -0,0 +1,140 @@ +package dnsprovider + +import ( + "context" + "errors" + "strings" + "testing" +) + +func scopeCredentials() map[string]string { + return map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com,sub.example.com", "allowed_domains": "www.sub.example.com,*.example.com"} +} + +func TestAliyunScopeBoundaries(t *testing.T) { + scope, err := ParseAliyunScope(scopeCredentials()) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ name, zone string }{ + {"www.sub.example.com", "sub.example.com"}, + {"WWW.EXAMPLE.COM.", "example.com"}, + {"*.example.com", "example.com"}, + {"example.com", ""}, + {"evil-example.com", ""}, + {"example.com.evil.net", ""}, + {"deep.www.example.com", ""}, + {"*.sub.example.com", ""}, + {"pcux.cn", ""}, + {"com", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + zone, err := scope.Resolve(tc.name) + if zone != tc.zone || (err != nil) != (tc.zone == "") { + t.Fatalf("范围校验错误:zone=%s err=%v", zone, err) + } + }) + } + for _, value := range []string{"", "com", "co.uk", "*.example.com", "example.com,", "example.com,co.uk"} { + credentials := scopeCredentials() + credentials["managed_zones"] = value + if _, err := ParseAliyunScope(credentials); err == nil { + t.Fatalf("不安全托管区未拒绝:%s", value) + } + } + credentials := scopeCredentials() + credentials["allowed_domains"] = "outside.net" + if _, err := ParseAliyunScope(credentials); err == nil { + t.Fatal("越界的允许范围必须整体拒绝") + } +} + +type fakeAliyunRecords struct { + zones, rrs, deleted []string + addErr, deleteErr error + canceledCleanup bool +} + +func (f *fakeAliyunRecords) Add(_ context.Context, zone, rr, _ string) (string, error) { + f.zones = append(f.zones, zone) + f.rrs = append(f.rrs, rr) + return "created-record-id", f.addErr +} + +func (f *fakeAliyunRecords) Delete(ctx context.Context, id string) error { + f.deleted = append(f.deleted, id) + f.canceledCleanup = ctx.Err() != nil + return f.deleteErr +} + +func TestAliyunProbeNeverFallsBackOrCallsCloudOutsideScope(t *testing.T) { + api := &fakeAliyunRecords{addErr: errors.New("Forbidden.RAM")} + checker := NewAliyunAuthorizationChecker() + constructed := 0 + checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) { constructed++; return api, nil } + credentials := scopeCredentials() + for _, name := range []string{"example.com", "outside.net", "pcux.cn", "*.sub.example.com"} { + if _, err := checker.Verify(t.Context(), name, credentials); err == nil { + t.Fatal("越界请求未拒绝") + } + } + if constructed != 0 { + t.Fatal("越界请求不能创建云客户端") + } + if _, err := checker.Verify(t.Context(), "www.sub.example.com", credentials); err == nil { + t.Fatal("RAM 拒绝必须返回失败") + } + if len(api.zones) != 1 || api.zones[0] != "sub.example.com" || !strings.HasSuffix(api.rrs[0], ".www") { + t.Fatalf("必须仅写授权子域:%v %v", api.zones, api.rrs) + } + if len(api.deleted) != 0 { + t.Fatal("新增失败不能猜测记录 ID 删除") + } + api.addErr = nil + api.deleteErr = errors.New("Forbidden.RAM") + report, err := checker.Verify(t.Context(), "www.sub.example.com", credentials) + if err == nil || report.Verified || !report.Domains[0].Writable || report.Domains[0].Cleanup { + t.Fatal("清理失败不能证明权限通过") + } + api.deleteErr = nil + report, err = checker.Verify(t.Context(), "www.sub.example.com", credentials) + if err != nil || !report.Verified || api.deleted[len(api.deleted)-1] != "created-record-id" { + t.Fatal("必须删除本次新增记录") + } +} + +func TestAliyunProviderPinsZoneAndCleanupIdentity(t *testing.T) { + scope, _ := ParseAliyunScope(scopeCredentials()) + api := &fakeAliyunRecords{} + p := &scopedAliyunProvider{scope: scope, api: api, records: make(map[[32]byte]string), challengeInfo: func(_ context.Context, name, _ string) (string, string, string) { + fqdn := "_acme-challenge." + name + "." + return fqdn, fqdn, "txt-value" + }} + if err := p.Present(t.Context(), "outside.net", "token", "key"); err == nil || len(api.zones) != 0 { + t.Fatal("实际 Provider 必须零调用拒绝越界") + } + if err := p.Present(t.Context(), "www.sub.example.com", "token", "key"); err != nil { + t.Fatal(err) + } + if len(api.zones) != 1 || api.zones[0] != "sub.example.com" || api.rrs[0] != "_acme-challenge.www" { + t.Fatal("实际写入没有锁定指定托管区") + } + if err := p.Present(t.Context(), "www.sub.example.com", "token", "key"); err == nil { + t.Fatal("重复挑战不能覆盖待清理 ID") + } + _ = p.CleanUp(t.Context(), "www.sub.example.com", "other", "key") + if len(api.deleted) != 0 { + t.Fatal("不能清理其他挑战") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := p.CleanUp(ctx, "www.sub.example.com", "token", "key"); err != nil || api.canceledCleanup || len(api.deleted) != 1 { + t.Fatal("请求取消后仍需有界清理本次 ID") + } + p.challengeInfo = func(context.Context, string, string) (string, string, string) { + return "_acme-challenge.www.sub.example.com.", "_acme-challenge.outside.net.", "value" + } + if err := p.Present(t.Context(), "www.sub.example.com", "new", "key"); err == nil || len(api.zones) != 1 { + t.Fatal("CNAME 越界不能调用云 API") + } +} diff --git a/backend/internal/dnsprovider/catalog.go b/backend/internal/dnsprovider/catalog.go index 67103f7..b467a89 100644 --- a/backend/internal/dnsprovider/catalog.go +++ b/backend/internal/dnsprovider/catalog.go @@ -48,11 +48,13 @@ var descriptors = map[string]Descriptor{ }, Aliyun: { ID: Aliyun, Name: "阿里云 DNS", Available: true, - Description: "使用 RAM AccessKey 创建和清理 DNS-01 测试记录。", + Description: "仅操作显式托管区内的允许域名。范围用英文逗号分隔;通配符只允许一层子域,不包含根域。旧配置需补充范围后重新验证。", CredentialFields: []CredentialField{ {Name: "access_key_id", Label: "AccessKey ID", Required: true}, {Name: "access_key_secret", Label: "AccessKey Secret", Secret: true, Required: true}, {Name: "security_token", Label: "Security Token", Secret: true}, + {Name: "managed_zones", Label: "明确托管区", Required: true, Placeholder: "例如 example.com,example.net;不自动尝试父域"}, + {Name: "allowed_domains", Label: "允许操作的域名", Required: true, Placeholder: "例如 www.example.com,*.example.net;根域须单独填写"}, }, }, DNSPod: { diff --git a/backend/internal/dnsprovider/lego_factory.go b/backend/internal/dnsprovider/lego_factory.go index 6207995..0fd165d 100644 --- a/backend/internal/dnsprovider/lego_factory.go +++ b/backend/internal/dnsprovider/lego_factory.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/go-acme/lego/v5/challenge" - "github.com/go-acme/lego/v5/providers/dns/alidns" "github.com/go-acme/lego/v5/providers/dns/cloudflare" "github.com/go-acme/lego/v5/providers/dns/gcloud" "github.com/go-acme/lego/v5/providers/dns/huaweicloud" @@ -73,15 +72,7 @@ func (LegoFactory) Create(providerID string, credentials map[string]string) (cha } return provider, nil case Aliyun: - cfg := alidns.NewDefaultConfig() - cfg.APIKey = cleaned["access_key_id"] - cfg.SecretKey = cleaned["access_key_secret"] - cfg.SecurityToken = cleaned["security_token"] - provider, err := alidns.NewDNSProviderConfig(cfg) - if err != nil { - return nil, fmt.Errorf("创建阿里云 DNS Provider 失败:%w", err) - } - return provider, nil + return newScopedAliyunProvider(cleaned) case DNSPod, TencentCloud: cfg := tencentcloud.NewDefaultConfig() cfg.SecretID = cleaned["secret_id"] diff --git a/backend/internal/dnsprovider/tester.go b/backend/internal/dnsprovider/tester.go index 5b7821a..065c41a 100644 --- a/backend/internal/dnsprovider/tester.go +++ b/backend/internal/dnsprovider/tester.go @@ -102,6 +102,9 @@ func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain st if err != nil { return report, err } + if err := ValidateAliyunDomainScope(providerID, []string{testDomain}, cleaned); err != nil { + return report, err + } provider, err := t.factory.Create(providerID, cleaned) if err != nil { return report, err diff --git a/backend/internal/dnsprovider/tester_test.go b/backend/internal/dnsprovider/tester_test.go index 2caf446..e9c796d 100644 --- a/backend/internal/dnsprovider/tester_test.go +++ b/backend/internal/dnsprovider/tester_test.go @@ -175,6 +175,8 @@ func TestLegoFactoryCreatesConfiguredProviders(t *testing.T) { credentials: map[string]string{ "access_key_id": "id", "access_key_secret": "secret", + "managed_zones": "example.com", + "allowed_domains": "example.com", }, }, { diff --git a/backend/internal/httpapi/aliyun_scope_api.go b/backend/internal/httpapi/aliyun_scope_api.go index b856df7..b607933 100644 --- a/backend/internal/httpapi/aliyun_scope_api.go +++ b/backend/internal/httpapi/aliyun_scope_api.go @@ -33,6 +33,7 @@ type aliyunScopeResponse struct { DiscoveredDomains []dnsprovider.AuthorizationDomain `json:"discovered_domains,omitempty"` ManagedDomains []string `json:"managed_domains"` CheckedAt string `json:"checked_at"` + UserScope *dnsprovider.AliyunScope `json:"user_scope,omitempty"` } func (s *Server) getAliyunDNSScope(c *gin.Context) { @@ -63,6 +64,14 @@ func (s *Server) getAliyunDNSScope(c *gin.Context) { CredentialRef: credentialRef, CheckedAt: s.now().UTC().Format("2006-01-02T15:04:05Z"), } + scope, scopeErr := dnsprovider.ParseAliyunScope(credentials) + if scopeErr != nil { + response.PolicyMode = "scope_required" + response.Message = scopeErr.Error() + c.JSON(http.StatusOK, response) + return + } + response.UserScope = &scope // 读取当前系统内已经纳管的域名列表 if s.repository != nil { @@ -136,6 +145,10 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) { return } + if err := dnsprovider.ValidateAliyunDomainScope(provider, []string{targetDomain}, credentials); err != nil { + c.JSON(http.StatusOK, gin.H{"verified": false, "domain": targetDomain, "message": err.Error()}) + return + } report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials) report = sanitizeAliyunReport(report, targetDomain) if verifyErr != nil { @@ -162,7 +175,7 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) { }) } -func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainName string) error { +func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNames ...string) error { if s == nil { return errors.New("域名授权检查服务未初始化") } @@ -189,10 +202,14 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil { return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err) } - - if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil { - diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName) - return fmt.Errorf("阿里云 RAM 账号无法操作域名 %s:%s", domainName, diagnosis) + if err := dnsprovider.ValidateAliyunDomainScope(provider, domainNames, credentials); err != nil { + return err + } + for _, domainName := range domainNames { + if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil { + diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName) + return fmt.Errorf("阿里云 RAM 账号无法操作域名 %s:%s", domainName, diagnosis) + } } return nil } diff --git a/backend/internal/httpapi/aliyun_scope_api_test.go b/backend/internal/httpapi/aliyun_scope_api_test.go index 526fc96..4343aa5 100644 --- a/backend/internal/httpapi/aliyun_scope_api_test.go +++ b/backend/internal/httpapi/aliyun_scope_api_test.go @@ -81,6 +81,8 @@ func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) { "secret://dns/aliyun/ref1": { "access_key_id": "LTAI_test", "access_key_secret": "test_secret", + "managed_zones": "solit.cn,unauth.com,forbidden.cn", + "allowed_domains": "solit.cn,*.solit.cn,unauth.com,forbidden.cn", }, }, } @@ -208,7 +210,7 @@ func TestAliyunDomainAPIRejectsPartiallyUnauthorizedSAN(t *testing.T) { server := New(Dependencies{ Config: cfg, Repository: &stubDomainRepository{}, Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{ - cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"}, + cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com", "allowed_domains": "example.com"}, }}, AliyunChecker: &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}}, }) diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go index c63dff1..9b8be16 100644 --- a/backend/internal/httpapi/auth.go +++ b/backend/internal/httpapi/auth.go @@ -69,6 +69,12 @@ func (s *Server) authLogin(c *gin.Context) { s.writeError(c, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试", nil) return } + if !s.reserveLogin(c.Request, key) { + c.Header("Retry-After", "60") + s.writeError(c, http.StatusTooManyRequests, "登录请求过于频繁或服务繁忙,请稍后再试", nil) + return + } + defer s.releaseLogin(c.Request, key) account, userID, passwordHash, err := s.authenticateAccount(c.Request.Context(), request, cfg) if err != nil { @@ -92,9 +98,15 @@ func (s *Server) authLogin(c *gin.Context) { expiresAt := minTime(now.Add(authSessionLifetime), absoluteExpiresAt) s.authMu.Lock() s.pruneAuthSessionsLocked(now) - if len(s.authSessions) >= authStateCapacity { + userSessions := 0 + for _, session := range s.authSessions { + if session.UserID == userID { + userSessions++ + } + } + if len(s.authSessions) >= authStateCapacity || userSessions >= authUserSessionCapacity { s.authMu.Unlock() - s.writeError(c, http.StatusServiceUnavailable, "登录会话数量已达上限,请稍后重试", nil) + s.writeError(c, http.StatusServiceUnavailable, "登录会话数量已达上限,请退出其他设备或稍后重试", nil) return } s.authSessions[token] = authSession{ @@ -180,7 +192,7 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) { s.authMu.RLock() session, ok := s.authSessions[token] s.authMu.RUnlock() - if !ok || !session.ExpiresAt.After(now) || (!session.AbsoluteExpiresAt.IsZero() && !session.AbsoluteExpiresAt.After(now)) { + if !ok || !session.ExpiresAt.After(now) || session.AbsoluteExpiresAt.IsZero() || !session.AbsoluteExpiresAt.After(now) || session.ExpiresAt.After(session.AbsoluteExpiresAt) { if ok { s.authMu.Lock() delete(s.authSessions, token) @@ -198,7 +210,7 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) { newExpiresAt := minTime(now.Add(authSessionLifetime), session.AbsoluteExpiresAt) s.authMu.Lock() current, exists := s.authSessions[token] - if !exists { + if !exists || current.UserID != session.UserID || current.PasswordHash != session.PasswordHash || current.Role != session.Role || current.Username != session.Username || current.Email != session.Email || !current.AbsoluteExpiresAt.Equal(session.AbsoluteExpiresAt) { s.authMu.Unlock() return authSession{}, false } @@ -216,11 +228,11 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) { } func (s *Server) authSessionAccountIsValid(ctx context.Context, session authSession) bool { - if session.UserID == "" { - return true + if strings.TrimSpace(session.UserID) == "" || strings.TrimSpace(session.PasswordHash) == "" || strings.TrimSpace(session.Username) == "" { + return false } - if session.PasswordHash == "" { - return true + if session.Role != repository.UserRoleUser && session.Role != repository.UserRoleAdmin && session.Role != repository.UserRoleSuperAdmin { + return false } if session.UserID == "primary-super-admin" { s.configMu.RLock() @@ -290,7 +302,7 @@ func (s *Server) allowLogin(key string) bool { if !attempt.BlockedTill.IsZero() && attempt.BlockedTill.After(now) { return false } - if attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow { + if attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow || (!attempt.BlockedTill.IsZero() && !attempt.BlockedTill.After(now)) { delete(s.loginAttempts, key) } return true diff --git a/backend/internal/httpapi/auth_admission.go b/backend/internal/httpapi/auth_admission.go new file mode 100644 index 0000000..5585db6 --- /dev/null +++ b/backend/internal/httpapi/auth_admission.go @@ -0,0 +1,67 @@ +package httpapi + +import ( + "net/http" + "strings" + "time" +) + +const ( + loginConcurrentLimit = 8 + loginSourceConcurrentLimit = 2 + loginSourceRequestLimit = 30 +) + +type loginSource struct { + WindowStart time.Time + Requests int + InFlight int +} + +// 在密码计算和数据库查询之前预约容量,不建立无限等待队列。 +// 来源只取真实 TCP 对端;不信任任意转发头。代理部署应另外配置边缘限流。 +func (s *Server) reserveLogin(request *http.Request, key string) bool { + sourceKey := strings.SplitN(loginAttemptKey(request, ""), "|", 2)[0] + now := s.now() + s.loginMu.Lock() + defer s.loginMu.Unlock() + for name, source := range s.loginSources { + if source.InFlight == 0 && now.Sub(source.WindowStart) >= time.Minute { + delete(s.loginSources, name) + } + } + source, exists := s.loginSources[sourceKey] + attempt := s.loginAttempts[key] + if attempt.BlockedTill.After(now) || attempt.Failures+s.loginPending[key] >= loginFailureLimit || + s.loginInFlight >= loginConcurrentLimit || source.InFlight >= loginSourceConcurrentLimit || + (!exists && len(s.loginSources) >= authStateCapacity) { + return false + } + if source.WindowStart.IsZero() || now.Sub(source.WindowStart) >= time.Minute { + source.WindowStart = now + source.Requests = 0 + } + if source.Requests >= loginSourceRequestLimit { + return false + } + source.Requests++ + source.InFlight++ + s.loginSources[sourceKey] = source + s.loginInFlight++ + s.loginPending[key]++ + return true +} + +func (s *Server) releaseLogin(request *http.Request, key string) { + sourceKey := strings.SplitN(loginAttemptKey(request, ""), "|", 2)[0] + s.loginMu.Lock() + defer s.loginMu.Unlock() + source := s.loginSources[sourceKey] + source.InFlight-- + s.loginSources[sourceKey] = source + s.loginInFlight-- + s.loginPending[key]-- + if s.loginPending[key] == 0 { + delete(s.loginPending, key) + } +} diff --git a/backend/internal/httpapi/auth_load_test.go b/backend/internal/httpapi/auth_load_test.go new file mode 100644 index 0000000..80692f0 --- /dev/null +++ b/backend/internal/httpapi/auth_load_test.go @@ -0,0 +1,185 @@ +package httpapi + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestIncompleteSessionsFailClosed(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.Admin.Username = "admin" + cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123") + for _, field := range []string{"user_id", "username", "hash", "role", "absolute", "inverted"} { + t.Run(field, func(t *testing.T) { + s := New(Dependencies{Config: cfg}) + cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0] + session := s.authSessions[cookie.Value] + switch field { + case "user_id": + session.UserID = "" + case "username": + session.Username = "" + case "hash": + session.PasswordHash = "" + case "role": + session.Role = "unknown" + case "absolute": + session.AbsoluteExpiresAt = time.Time{} + case "inverted": + session.AbsoluteExpiresAt = session.ExpiresAt.Add(-time.Second) + } + s.authSessions[cookie.Value] = session + if response := authSessionRequest(s, cookie); response.Code != http.StatusUnauthorized { + t.Fatal("不完整会话必须拒绝") + } + if _, ok := s.authSessions[cookie.Value]; ok { + t.Fatal("失效会话必须移除") + } + }) + } +} + +func TestLoginAdmissionConcurrentBurstAndRecovery(t *testing.T) { + s := New(Dependencies{Config: readyTestConfig(t.TempDir())}) + const workers = 512 + start, release := make(chan struct{}), make(chan struct{}) + var attempted sync.WaitGroup + var done sync.WaitGroup + var accepted atomic.Int32 + attempted.Add(workers) + done.Add(workers) + for i := 0; i < workers; i++ { + go func(i int) { + defer done.Done() + <-start + r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) + r.RemoteAddr = fmt.Sprintf("192.0.2.%d:1234", i%250+1) + key := loginAttemptKey(r, fmt.Sprint(i)) + ok := s.reserveLogin(r, key) + if ok { + accepted.Add(1) + } + attempted.Done() + if ok { + <-release + s.releaseLogin(r, key) + } + }(i) + } + close(start) + attempted.Wait() + if accepted.Load() != loginConcurrentLimit { + t.Errorf("并发容量错误:%d", accepted.Load()) + } + close(release) + done.Wait() + if s.loginInFlight != 0 || len(s.loginPending) != 0 { + t.Fatal("并发预约没有完整释放") + } + r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) + r.RemoteAddr = "198.51.100.1:1234" + key := loginAttemptKey(r, "normal") + if !s.reserveLogin(r, key) { + t.Fatal("突发结束后正常用户无法恢复") + } + s.releaseLogin(r, key) +} + +func TestRandomIdentifiersCannotExhaustGlobalLoginState(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.Admin.Username = "admin" + cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123") + s := New(Dependencies{Config: cfg}) + now := time.Now() + s.now = func() time.Time { return now } + for i := 0; i < 5000; i++ { + r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(fmt.Sprintf(`{"identifier":"unknown-%d","password":"wrong"}`, i))) + r.RemoteAddr = "192.0.2.1:1234" + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if i >= loginSourceRequestLimit && w.Code != http.StatusTooManyRequests { + t.Fatalf("随机账号绕过来源限流:%d", w.Code) + } + } + if len(s.loginAttempts) > loginSourceRequestLimit { + t.Fatal("单一来源挤占了全局失败表") + } + normalRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`)) + normalRequest.RemoteAddr = "198.51.100.1:1234" + response := httptest.NewRecorder() + s.Handler().ServeHTTP(response, normalRequest) + if response.Code != http.StatusOK { + t.Fatal("独立来源正常用户不能登录") + } + now = now.Add(2 * time.Minute) + r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`)) + r.RemoteAddr = "192.0.2.1:1234" + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("来源窗口结束后无法恢复:%d", w.Code) + } +} + +func TestLoginAttemptTableSaturationRecoversAfterWindow(t *testing.T) { + s := New(Dependencies{Config: readyTestConfig(t.TempDir())}) + now := time.Now() + s.now = func() time.Time { return now } + for i := 0; i < authStateCapacity; i++ { + s.recordLoginFailure(fmt.Sprint(i)) + } + if s.allowLogin("normal") { + t.Fatal("满容量不能无界增加新键") + } + now = now.Add(loginWindow + time.Second) + if !s.allowLogin("normal") || len(s.loginAttempts) != 0 { + t.Fatal("窗口到期后未恢复") + } +} + +func TestLoginBlockExpiresAndUserSessionQuotaRecovers(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.Admin.Username = "admin" + cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123") + s := New(Dependencies{Config: cfg}) + now := time.Now() + s.now = func() time.Time { return now } + r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) + key := loginAttemptKey(r, "admin") + for i := 0; i < loginFailureLimit; i++ { + s.recordLoginFailure(key) + } + if s.allowLogin(key) { + t.Fatal("封禁期间不能登录") + } + now = now.Add(loginBlockDuration + time.Second) + if !s.allowLogin(key) || !s.reserveLogin(r, key) { + t.Fatal("封禁到期后必须恢复预约") + } + s.releaseLogin(r, key) + var first *http.Cookie + for i := 0; i < authUserSessionCapacity; i++ { + cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0] + if first == nil { + first = cookie + } + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`)) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, request) + if w.Code != http.StatusServiceUnavailable { + t.Fatal("单账号不能占满全局会话容量") + } + logout := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + logout.AddCookie(first) + s.Handler().ServeHTTP(httptest.NewRecorder(), logout) + if loginAuthTest(t, s, "admin", "AdminPass123", false).Code != http.StatusOK { + t.Fatal("主动退出后不能恢复登录") + } +} diff --git a/backend/internal/httpapi/auth_retention.go b/backend/internal/httpapi/auth_retention.go index 7ab2a3c..06ade43 100644 --- a/backend/internal/httpapi/auth_retention.go +++ b/backend/internal/httpapi/auth_retention.go @@ -3,6 +3,7 @@ package httpapi import "time" const authStateCapacity = 4096 +const authUserSessionCapacity = 16 // 调用方持有认证锁;会话表有容量上限,不淘汰仍然有效的用户会话。 func (s *Server) pruneAuthSessionsLocked(now time.Time) { diff --git a/backend/internal/httpapi/certificate_restore_api_test.go b/backend/internal/httpapi/certificate_restore_api_test.go index c7c50d0..b0e5b2e 100644 --- a/backend/internal/httpapi/certificate_restore_api_test.go +++ b/backend/internal/httpapi/certificate_restore_api_test.go @@ -41,6 +41,8 @@ func newRestoreAPITestServer(t *testing.T, restore func(context.Context, string, audit := &restoreAuditRepository{} cfg := config.Default(t.TempDir()) cfg.SetupReady = true + cfg.Admin.Username = "admin" + cfg.Admin.PasswordHash = httpUserPasswordHash(t, "RestoreTest123") server := New(Dependencies{ Config: cfg, Repository: &stubDomainRepository{}, @@ -49,9 +51,13 @@ func newRestoreAPITestServer(t *testing.T, restore func(context.Context, string, Audit: audit, }) server.authSessions["test-session"] = authSession{ - Username: "admin", - Role: "super_admin", - ExpiresAt: time.Now().Add(time.Hour), + UserID: "primary-super-admin", + PasswordHash: cfg.Admin.PasswordHash, + Email: cfg.Admin.Email, + Username: "admin", + Role: "super_admin", + ExpiresAt: time.Now().Add(time.Hour), + AbsoluteExpiresAt: time.Now().Add(2 * time.Hour), } return server, audit } diff --git a/backend/internal/httpapi/report_safety_test.go b/backend/internal/httpapi/report_safety_test.go index 6f4a853..4509cad 100644 --- a/backend/internal/httpapi/report_safety_test.go +++ b/backend/internal/httpapi/report_safety_test.go @@ -47,7 +47,7 @@ func TestAliyunReportHidesSDKErrorAndPreservesCleanupFailure(t *testing.T) { cfg.DNS.CredentialRef = "secret://test/only" server := New(Dependencies{ Config: cfg, AliyunChecker: &unsafeReportChecker{}, - Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"}}}, + Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.test", "allowed_domains": "example.test"}}}, }) response := httptest.NewRecorder() serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", strings.NewReader(`{"domain":"example.test"}`))) diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index c90fe75..b08c3b6 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -106,6 +106,9 @@ type Server struct { authMu sync.RWMutex loginAttempts map[string]loginAttempt loginMu sync.Mutex + loginSources map[string]loginSource + loginPending map[string]int + loginInFlight int loginPrunedAt time.Time now func() time.Time httpServer *http.Server @@ -144,6 +147,8 @@ func New(deps Dependencies) *Server { resetLimiter: newResetRateLimiter(), authSessions: make(map[string]authSession), loginAttempts: make(map[string]loginAttempt), + loginSources: make(map[string]loginSource), + loginPending: make(map[string]int), now: time.Now, } router.Use(server.requestMiddleware()) @@ -361,10 +366,17 @@ func (s *Server) ready(c *gin.Context) { continue } checked[reference] = struct{}{} - if _, err := reader.Get(ctx, reference); err != nil { + credentials, err := reader.Get(ctx, reference) + if err != nil { s.writeReadinessFailure(c, "credential_unavailable") return } + if reference == dnsReference && cfg.DNS.Provider == dnsprovider.Aliyun { + if _, err := dnsprovider.ParseAliyunScope(credentials); err != nil { + s.writeReadinessFailure(c, "dns_scope_required") + return + } + } } for _, path := range []string{cfg.Server.TLSCert, cfg.Server.TLSKey} { @@ -460,19 +472,10 @@ func (s *Server) createDomain(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "域名配置无效", err) return } - if err := s.validateDomainAliyunAuthorization(c.Request.Context(), item.Name); err != nil { + if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil { s.writeError(c, http.StatusBadRequest, err.Error(), err) return } - for _, san := range item.SANs { - if san == item.Name { - continue - } - if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil { - s.writeError(c, http.StatusBadRequest, err.Error(), err) - return - } - } if err := s.repository.Create(c.Request.Context(), item); err != nil { if repository.IsConflict(err) { s.writeError(c, http.StatusConflict, "域名已经存在", err) @@ -507,19 +510,10 @@ func (s *Server) updateDomain(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "域名配置无效", err) return } - if err := s.validateDomainAliyunAuthorization(c.Request.Context(), item.Name); err != nil { + if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil { s.writeError(c, http.StatusBadRequest, err.Error(), err) return } - for _, san := range item.SANs { - if san == item.Name { - continue - } - if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil { - s.writeError(c, http.StatusBadRequest, err.Error(), err) - return - } - } item.ID = existing.ID item.CreatedAt = existing.CreatedAt item.Status = "pending" diff --git a/backend/internal/httpapi/settings_api.go b/backend/internal/httpapi/settings_api.go index 24b6bd5..d67821b 100644 --- a/backend/internal/httpapi/settings_api.go +++ b/backend/internal/httpapi/settings_api.go @@ -12,6 +12,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" ) type settingsView struct { @@ -54,9 +55,11 @@ type settingsACMEView struct { } type settingsDNSView struct { - Provider string `json:"provider"` - VerificationState string `json:"verification_state"` - CredentialConfigured bool `json:"credential_configured"` + Provider string `json:"provider"` + VerificationState string `json:"verification_state"` + CredentialConfigured bool `json:"credential_configured"` + UserScope *dnsprovider.AliyunScope `json:"user_scope,omitempty"` + ScopeMessage string `json:"scope_message,omitempty"` } type settingsNotificationView struct { @@ -163,7 +166,24 @@ func (s *Server) getSettings(c *gin.Context) { s.configMu.RLock() cfg := s.config s.configMu.RUnlock() - c.JSON(http.StatusOK, settingsViewFromConfig(cfg)) + view := settingsViewFromConfig(cfg) + if cfg.DNS.Provider == dnsprovider.Aliyun { + credentials, err := s.readDNSCredentials(c.Request.Context(), cfg.DNS.CredentialRef) + if err == nil { + scope, scopeErr := dnsprovider.ParseAliyunScope(credentials) + if scopeErr == nil { + view.DNS.UserScope = &scope + } else { + view.DNS.ScopeMessage = scopeErr.Error() + } + } else { + view.DNS.ScopeMessage = "无法读取已保存授权范围,请检查凭据存储或重新验证" + } + if view.DNS.UserScope == nil { + view.DNS.VerificationState = "scope_required" + } + } + c.JSON(http.StatusOK, view) } func (s *Server) updateSettings(c *gin.Context) { diff --git a/backend/internal/httpapi/users_api_test.go b/backend/internal/httpapi/users_api_test.go index 1e091f3..967b716 100644 --- a/backend/internal/httpapi/users_api_test.go +++ b/backend/internal/httpapi/users_api_test.go @@ -3,6 +3,7 @@ package httpapi import ( "bytes" "context" + "database/sql" "encoding/json" "net/http" "net/http/httptest" @@ -134,16 +135,59 @@ func serveUserRequest(server *Server, cookie *http.Cookie, method, path, body st func httpUserSessionCookie(server *Server, userID, role string) *http.Cookie { token := userID + "-session" - now := time.Now() + now := server.now() + hash, err := bcrypt.GenerateFromPassword([]byte("FixturePass123"), bcrypt.MinCost) + if err != nil { + panic(err) + } + user := repository.User{ID: userID, Username: userID, Role: role, Status: repository.UserStatusActive, PasswordHash: string(hash)} + if userID == "primary-super-admin" { + user.Username = server.config.Admin.Username + user.Email = server.config.Admin.Email + user.PasswordHash = server.config.Admin.PasswordHash + } else { + if server.users == nil { + server.users = &sessionFixtureUsers{items: make(map[string]repository.User)} + } + if stored, err := server.users.GetUserByID(context.Background(), userID); err == nil { + user = stored + } else if err := server.users.CreateUser(context.Background(), user); err != nil { + panic(err) + } + } server.authMu.Lock() server.authSessions[token] = authSession{ - UserID: userID, Username: userID, Role: role, + UserID: user.ID, Username: user.Username, Email: user.Email, Role: user.Role, PasswordHash: user.PasswordHash, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour), } server.authMu.Unlock() return &http.Cookie{Name: authCookieName, Value: token, Path: "/"} } +type sessionFixtureUsers struct { + repository.UserRepository + items map[string]repository.User +} + +func (r *sessionFixtureUsers) GetUserByID(_ context.Context, id string) (repository.User, error) { + if user, ok := r.items[id]; ok { + return user, nil + } + return repository.User{}, sql.ErrNoRows +} + +func (r *sessionFixtureUsers) CreateUser(_ context.Context, user repository.User) error { + r.items[user.ID] = user + return nil +} + +func (r *sessionFixtureUsers) EnsureSuperAdmin(_ context.Context, user repository.User) error { + user.Role = repository.UserRoleSuperAdmin + user.Status = repository.UserStatusActive + r.items[user.ID] = user + return nil +} + func httpUserPasswordHash(t *testing.T, password string) string { t.Helper() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) diff --git a/backend/internal/setup/dns_verification_test.go b/backend/internal/setup/dns_verification_test.go index cda995f..8899a9a 100644 --- a/backend/internal/setup/dns_verification_test.go +++ b/backend/internal/setup/dns_verification_test.go @@ -11,6 +11,27 @@ type fakeDNSTester struct { err error } +func TestDNSVerificationBindsScopeToNewCredentialReference(t *testing.T) { + store := &memorySecretWriter{} + manager := NewDNSVerificationManager(fakeDNSTester{}, store) + input := DNSVerificationInput{ProviderID: "aliyun", TestDomain: "www.example.com", Credentials: map[string]string{"managed_zones": "example.com", "allowed_domains": "www.example.com"}} + first, err := manager.Verify(t.Context(), input) + if err != nil { + t.Fatal(err) + } + input.Credentials["allowed_domains"] = "*.example.com" + second, err := manager.Verify(t.Context(), input) + if err != nil { + t.Fatal(err) + } + if first.CredentialRef == second.CredentialRef || store.values[first.CredentialRef]["allowed_domains"] != "www.example.com" { + t.Fatal("范围变更不能覆盖已验证引用") + } + if err := manager.Check(first.VerificationToken, "aliyun", second.CredentialRef, input.TestDomain); err == nil { + t.Fatal("旧令牌不能授权新范围") + } +} + func (f fakeDNSTester) Verify(context.Context, string, string, map[string]string) (DNSVerificationReport, error) { return DNSVerificationReport{}, f.err } diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index a4c5951..60f2027 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -53,6 +53,18 @@ test("管理设置拒绝过期验证令牌", async (t) => { assert.equal(state.dnsReadyToSave.value, false); }); +test("管理设置变更托管区不能复用验证令牌", async (t) => { + const state = await setup(t, () => verifiedResponse()); + await fill(state); + state.settingsForm.value.dns.credentials.managed_zones = "example.test"; + state.settingsForm.value.dns.credentials.allowed_domains = "example.test"; + await state.testDNS(); + assert.equal(state.dnsReadyToSave.value, true); + state.settingsForm.value.dns.credentials.managed_zones = "other.test"; + assert.equal(state.dnsReadyToSave.value, false); + assert.equal(state.settingsForm.value.dns.verification_token, ""); +}); + test("关闭设置后不显示旧 DNS 请求的成功提示", async (t) => { const pending = deferred(); const state = await setup(t, () => pending.promise); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index ca229e7..366c55c 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -538,6 +538,14 @@ const { /> +

+ {{ settingsForm.dns.scope_message }} +

+
+

已保存托管区:{{ settingsForm.dns.user_scope.managed_zones.join(",") }}

+

已保存允许域名:{{ settingsForm.dns.user_scope.allowed_domains.join(",") }}

+

修改范围需重新填写凭据并在线验证,不会自动扩大已有授权。

+