diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index 5e6cc7a..938f4e4 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -139,7 +139,7 @@ func (c *AliyunAuthorizationChecker) verifyResolved(ctx context.Context, request result.Domains[0].Cleanup = true }() describeCtx, describeCancel := context.WithTimeout(ctx, aliyunAPICallTimeout) - info, err := api.Describe(describeCtx, recordID) + info, err := api.Describe(describeCtx, zone, rr, recordID) describeCancel() if err != nil { resultErr = fmt.Errorf("查询授权探测 TXT 记录失败:%w", err) @@ -161,7 +161,7 @@ func (c *AliyunAuthorizationChecker) verifyResolved(ctx context.Context, request func matchesAliyunAuthorizationRecord(info aliyunRecordInfo, recordID, zone, rr, value string) bool { return strings.TrimSpace(info.RecordID) == strings.TrimSpace(recordID) && normalizeAuthorizationDomain(info.DomainName) == normalizeAuthorizationDomain(zone) && - strings.TrimSpace(info.RR) == strings.TrimSpace(rr) && + strings.EqualFold(strings.TrimSpace(info.RR), strings.TrimSpace(rr)) && strings.EqualFold(strings.TrimSpace(info.Type), "TXT") && info.Value == value } diff --git a/backend/internal/dnsprovider/aliyun_authorization_verify_test.go b/backend/internal/dnsprovider/aliyun_authorization_verify_test.go index 2c5b94a..89f1805 100644 --- a/backend/internal/dnsprovider/aliyun_authorization_verify_test.go +++ b/backend/internal/dnsprovider/aliyun_authorization_verify_test.go @@ -39,7 +39,10 @@ func (a *authorizationRecordAPI) Add(ctx context.Context, zone, rr, value string return "authorization-record-id", nil } -func (a *authorizationRecordAPI) Describe(ctx context.Context, recordID string) (aliyunRecordInfo, error) { +func (a *authorizationRecordAPI) Describe(ctx context.Context, zone, rr, recordID string) (aliyunRecordInfo, error) { + if zone != a.zone || rr != a.rr { + return aliyunRecordInfo{}, errors.New("查询必须限定本次创建记录的托管区和主机记录") + } a.recordCall("describe", recordID, ctx) if a.describeErr != nil { return aliyunRecordInfo{}, a.describeErr diff --git a/backend/internal/dnsprovider/aliyun_provider.go b/backend/internal/dnsprovider/aliyun_provider.go index 3154ee5..9096cf6 100644 --- a/backend/internal/dnsprovider/aliyun_provider.go +++ b/backend/internal/dnsprovider/aliyun_provider.go @@ -15,7 +15,7 @@ import ( type aliyunRecordAPI interface { Add(context.Context, string, string, string) (string, error) - Describe(context.Context, string) (aliyunRecordInfo, error) + Describe(context.Context, string, string, string) (aliyunRecordInfo, error) Delete(context.Context, string) error } @@ -44,26 +44,40 @@ func (a aliyunRecords) Add(ctx context.Context, zone, rr, value string) (string, return strings.TrimSpace(*response.Body.RecordId), nil } -func (a aliyunRecords) Describe(ctx context.Context, recordID string) (aliyunRecordInfo, error) { - response, err := client.DescribeDomainRecordInfoWithContext( +func (a aliyunRecords) Describe(ctx context.Context, zone, rr, recordID string) (aliyunRecordInfo, error) { + zone = normalizeAuthorizationDomain(zone) + rr = strings.TrimSpace(rr) + recordID = strings.TrimSpace(recordID) + if zone == "" || rr == "" || recordID == "" { + return aliyunRecordInfo{}, errors.New("精确查询临时记录缺少托管区、主机记录或记录 ID") + } + // 按唯一托管区查询,兼容仅授予域名资源权限的 RAM 策略,不扩大账号可见范围。 + response, err := client.DescribeDomainRecordsWithContext( ctx, a.client, - new(client.DescribeDomainRecordInfoRequest).SetRecordId(recordID), + new(client.DescribeDomainRecordsRequest).SetDomainName(zone).SetRRKeyWord(rr). + SetTypeKeyWord("TXT").SetSearchMode("EXACT").SetPageNumber(1).SetPageSize(100), &dara.RuntimeOptions{}, ) if err != nil { return aliyunRecordInfo{}, err } - if response == nil || response.Body == nil { + if response == nil || response.Body == nil || response.Body.DomainRecords == nil { return aliyunRecordInfo{}, errors.New("阿里云未返回临时 TXT 记录详情") } - return aliyunRecordInfo{ - RecordID: strings.TrimSpace(dereferenceString(response.Body.RecordId)), - DomainName: normalizeAuthorizationDomain(dereferenceString(response.Body.DomainName)), - RR: strings.TrimSpace(dereferenceString(response.Body.RR)), - Type: strings.TrimSpace(dereferenceString(response.Body.Type)), - Value: dereferenceString(response.Body.Value), - }, nil + for _, record := range response.Body.DomainRecords.Record { + if record == nil || strings.TrimSpace(dereferenceString(record.RecordId)) != recordID { + continue + } + return aliyunRecordInfo{ + RecordID: recordID, + DomainName: normalizeAuthorizationDomain(dereferenceString(record.DomainName)), + RR: strings.TrimSpace(dereferenceString(record.RR)), + Type: strings.TrimSpace(dereferenceString(record.Type)), + Value: dereferenceString(record.Value), + }, nil + } + return aliyunRecordInfo{}, errors.New("限定托管区查询未返回本次创建的临时 TXT 记录") } func (a aliyunRecords) Delete(ctx context.Context, id string) error { diff --git a/backend/internal/dnsprovider/aliyun_record_match_test.go b/backend/internal/dnsprovider/aliyun_record_match_test.go new file mode 100644 index 0000000..c1f558e --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_record_match_test.go @@ -0,0 +1,28 @@ +package dnsprovider + +import "testing" + +func TestAliyunAuthorizationRecordDNSCaseAndProof(t *testing.T) { + base := aliyunRecordInfo{RecordID: "record-1", DomainName: "example.com", RR: "_scert-authorization-mixed.web", Type: "TXT", Value: "ExactProofAa"} + tests := []struct { + name string + change func(*aliyunRecordInfo) + want bool + }{ + {"DNS名称大小写归一化", func(info *aliyunRecordInfo) {}, true}, + {"不同记录ID拒绝", func(info *aliyunRecordInfo) { info.RecordID = "record-2" }, false}, + {"不同域名拒绝", func(info *aliyunRecordInfo) { info.DomainName = "other.com" }, false}, + {"不同主机记录拒绝", func(info *aliyunRecordInfo) { info.RR += ".other" }, false}, + {"不同类型拒绝", func(info *aliyunRecordInfo) { info.Type = "CNAME" }, false}, + {"TXT值大小写不可忽略", func(info *aliyunRecordInfo) { info.Value = "exactproofaa" }, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + info := base + test.change(&info) + if got := matchesAliyunAuthorizationRecord(info, "record-1", "example.com", "_scert-authorization-MiXeD.web", "ExactProofAa"); got != test.want { + t.Fatalf("记录比对结果错误:got=%v want=%v", got, test.want) + } + }) + } +} diff --git a/backend/internal/dnsprovider/aliyun_records_test.go b/backend/internal/dnsprovider/aliyun_records_test.go new file mode 100644 index 0000000..8f721b7 --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_records_test.go @@ -0,0 +1,68 @@ +package dnsprovider + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + "github.com/go-acme/alidns-20150109/v5/client" +) + +func TestAliyunRecordsDescribeUsesScopedExactQuery(t *testing.T) { + for _, found := range []bool{true, false} { + name := "记录存在" + if !found { + name = "拒绝其他记录" + } + t.Run(name, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if got := r.Header.Get("x-acs-action"); got != "DescribeDomainRecords" { + t.Errorf("调用了非限定查询接口:%q", got) + } + for key, expected := range map[string]string{ + "DomainName": "example.com", + "RRKeyWord": "_scert-authorization-mixed.web", "TypeKeyWord": "TXT", + "SearchMode": "EXACT", "PageNumber": "1", "PageSize": "100", + } { + if got := r.Form.Get(key); got != expected { + t.Errorf("查询范围错误:%s=%q,预期 %q", key, got, expected) + } + } + records := []map[string]string{{"RecordId": "other-record", "DomainName": "example.com", "RR": "_scert-authorization-mixed.web", "Type": "TXT", "Value": "unrelated"}} + if found { + records = append(records, map[string]string{"RecordId": "record-1", "DomainName": "example.com", "RR": "_scert-authorization-mixed.web", "Type": "TXT", "Value": "ExactProofAa"}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"TotalCount": len(records), "PageNumber": 1, "PageSize": 100, "DomainRecords": map[string]any{"Record": records}}) + })) + defer server.Close() + sdk, err := client.NewClient(new(openapi.Config).SetAccessKeyId("test-id").SetAccessKeySecret("test-secret").SetEndpoint(strings.TrimPrefix(server.URL, "http://")).SetProtocol("http")) + if err != nil { + t.Fatal(err) + } + api := aliyunRecords{client: sdk} + info, err := api.Describe(t.Context(), "example.com", "_scert-authorization-mixed.web", "record-1") + if found { + if err != nil || !matchesAliyunAuthorizationRecord(info, "record-1", "example.com", "_scert-authorization-MiXeD.web", "ExactProofAa") { + t.Fatalf("限定范围查询未通过:%+v %v", info, err) + } + } else if err == nil { + t.Fatal("不得使用其他记录证明本次验证成功") + } + if calls != 1 { + t.Fatalf("应只执行一次限定查询,实际 %d 次", calls) + } + if _, err := api.Describe(t.Context(), "", "", "record-1"); err == nil || calls != 1 { + t.Fatal("缺少查询范围必须在调用 API 前拒绝") + } + }) + } +} diff --git a/backend/internal/dnsprovider/aliyun_scope_test.go b/backend/internal/dnsprovider/aliyun_scope_test.go index 9fd03ed..f6dfbc1 100644 --- a/backend/internal/dnsprovider/aliyun_scope_test.go +++ b/backend/internal/dnsprovider/aliyun_scope_test.go @@ -128,7 +128,7 @@ func (f *fakeAliyunRecords) Add(_ context.Context, zone, rr, value string) (stri return "created-record-id", nil } -func (f *fakeAliyunRecords) Describe(_ context.Context, id string) (aliyunRecordInfo, error) { +func (f *fakeAliyunRecords) Describe(_ context.Context, zone, rr, id string) (aliyunRecordInfo, error) { f.described = append(f.described, id) if f.describeErr != nil { return aliyunRecordInfo{}, f.describeErr