From 62eb03caa8ac4b554d58f25b160e4bd1008d0018 Mon Sep 17 00:00:00 2001 From: Episkey Date: Tue, 14 Jul 2026 22:53:32 -0700 Subject: [PATCH 1/2] fix(platform): normalize project-id inside GenericRequest payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 平台补全给出的 project 候选是 "org-xxx/ProjectName"(cmd/project.go getProjectList),平台 handler 负责还原成纯 id。但 SDK 的 BaseGenericRequest 把 GetProjectId() override 成 payload 优先、却没有 override SetProjectId: SetProjectId 写进 CommonBase,而 GetPayload() 末尾用 payload 覆盖 CommonBase (ucloud/request/generic.go),于是平台的归一化被 payload 里的原值吃掉。 凡是把 ProjectId 放进 generic payload map 的产品都会中招,master 上已有三处: - products/umongodb/internal/umongodb/create_replset.go:61, completion.go:56 - products/utidb/internal/tidb/api.go:49 - products/sqlserver/internal/sqlserver/create.go:89, create_alwayson.go:70 用户按 Tab 补全 --project-id 后 "org-x/Name" 原样上行,网关报 RetCode 292 "Project [org-x/Name] not exists"(2026-07-14 对 api.ucloud.cn 实测)。 不按 Tab、手敲纯 id 不受影响,故一直未被发现。 修复:归一化后若值确实变了,把结果同步进 GenericRequest 的 payload。未发生 归一化时(已是纯 id 或为空,即绝大多数调用)直接返回,payload 一字不动, 行为与历史逐字节一致。 测试覆盖 master 上全部 generic 产品的传法(payload map / 仅 CommonBase / 无 project-id / typed request),断言 wire 上的最终值而非中间态。 --- cmd/internal/platform/client.go | 40 +++- .../platform/normalize_projectid_test.go | 171 ++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 cmd/internal/platform/normalize_projectid_test.go diff --git a/cmd/internal/platform/client.go b/cmd/internal/platform/client.go index 99cd966a4a..6a7e918d2d 100644 --- a/cmd/internal/platform/client.go +++ b/cmd/internal/platform/client.go @@ -161,6 +161,43 @@ func BuildCredentialFrom(credConfig *CredentialConfig) *auth.Credential { return buildCredential(credConfig) } +// normalizeProjectID 把 project-id 从补全带出的 "id/name" 形态还原成纯 id +// (补全候选由 getProjectList 生成,形如 "org-xxx/ProjectName")。 +// +// typed 请求 SetProjectId 写进 CommonBase 即可。GenericRequest 要多一步:SDK 的 +// BaseGenericRequest 把 GetProjectId() override 成 payload 优先,却没有 override +// SetProjectId —— SetProjectId 写的是 CommonBase,而 GetPayload() 末尾用 payload +// 覆盖 CommonBase(SDK ucloud/request/generic.go),于是归一化被 payload 里的原值 +// 吃掉,"org-x/Name" 原样上行,网关报 RetCode 292 Project [org-x/Name] not exists +// (2026-07-14 对 api.ucloud.cn 实测)。凡是把 ProjectId 放进 payload map 的产品 +// 都会中招,故在此一并同步 payload。 +// +// 未发生归一化时(已是纯 id 或为空,即绝大多数调用)直接返回,payload 一字不动 —— +// 行为与历史逐字节一致。 +func normalizeProjectID(req request.Common) (request.Common, error) { + raw := req.GetProjectId() + normalized := PickResourceID(raw) + if err := req.SetProjectId(normalized); err != nil { + return req, err + } + if raw == normalized { + return req, nil + } + gr, ok := req.(request.GenericRequest) + if !ok { + return req, nil + } + payload := gr.GetPayload() + if _, exists := payload["ProjectId"]; !exists { + return req, nil + } + payload["ProjectId"] = normalized + if err := gr.SetPayload(payload); err != nil { + return req, err + } + return req, nil +} + // attachHandlers 把三个平台 handler 挂到 service client 上: // project-id 归一化、凭据头注入、oauth 反应式重试。 // credConfig 与 ac 显式传入:NewClient 借此传它自己的构造来源 profile(ac), @@ -172,8 +209,7 @@ func attachHandlersWithManager(sc sdk.ServiceClient, credConfig *CredentialConfi credConfig = &CredentialConfig{} } sc.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) { - err := req.SetProjectId(PickResourceID(req.GetProjectId())) - return req, err + return normalizeProjectID(req) }) // Platform request logging: every API request is logged uniformly at the SDK // layer (replaces per-command hand-rolled logging; products no longer build diff --git a/cmd/internal/platform/normalize_projectid_test.go b/cmd/internal/platform/normalize_projectid_test.go new file mode 100644 index 0000000000..1a8728c124 --- /dev/null +++ b/cmd/internal/platform/normalize_projectid_test.go @@ -0,0 +1,171 @@ +package platform + +import ( + "testing" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" +) + +// projectIDOnWire 按 SDK 默认 form 编码器编码 req,返回真正会上行的 ProjectId。 +// 断言 wire 值而不是 GetPayload() 中间态:产品踩的坑正是「中间态已归一化、 +// wire 上却是原值」,只有编码结果能证伪。 +func projectIDOnWire(t *testing.T, req request.Common) (string, bool) { + t.Helper() + form, err := request.EncodeForm(req) + if err != nil { + t.Fatalf("encode form: %v", err) + } + v, ok := form["ProjectId"] + return v, ok +} + +// TestNormalizeProjectIDAcrossProductShapes 覆盖 master 上各产品传 project-id 的全部 +// 形态(每条 name 标注取样来源),断言 wire 上的最终值。 +// +// 平台补全 getProjectList 给出的候选是 "org-xxx/ProjectName"(cmd/project.go), +// 平台 handler 负责还原成纯 id。把 ProjectId 放进 generic payload map 的产品, +// 归一化会被 SDK 的 payload 覆盖语义吃掉 —— 详见 normalizeProjectID 的注释。 +func TestNormalizeProjectIDAcrossProductShapes(t *testing.T) { + const idName = "org-x/MyProject" + const bare = "org-x" + + tests := []struct { + name string + build func(t *testing.T) request.Common + wantWire string + wantSet bool + }{ + { + // 取样:umongodb create_replset.go:61 / utidb api.go:49 + // sqlserver create.go:89 / pgsql(#127) supabase params() + // 这是本次修复的目标形态:修复前 wire 上是 "org-x/MyProject", + // 网关报 RetCode 292 Project [org-x/MyProject] not exists。 + name: "generic payload map + id/name (umongodb/utidb/sqlserver/pgsql-supabase)", + build: func(t *testing.T) request.Common { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{"Action": "X", "ProjectId": idName}); err != nil { + t.Fatal(err) + } + return gr + }, + wantWire: bare, wantSet: true, + }, + { + // 同上形态但用户传的已是纯 id(不按 Tab 补全)—— 修复前后行为必须一致。 + name: "generic payload map + bare id (未按 Tab,最常见)", + build: func(t *testing.T) request.Common { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{"Action": "X", "ProjectId": bare}); err != nil { + t.Fatal(err) + } + return gr + }, + wantWire: bare, wantSet: true, + }, + { + // 取样:cloudwatch query_metric_data.go:180 (BindProjectID 绑 CommonBase) + // ukafka list.go:60 (genReq.SetProjectId) + // payload 不含 ProjectId → 一直是好的,本次修复不得改变它。 + name: "generic CommonBase only + id/name (cloudwatch/ukafka)", + build: func(t *testing.T) request.Common { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{"Action": "X"}); err != nil { + t.Fatal(err) + } + if err := gr.SetProjectId(idName); err != nil { + t.Fatal(err) + } + return gr + }, + wantWire: bare, wantSet: true, + }, + { + // 取样:mysql create.go:50 (payload 只有 DBVersion/Region/Zone) / uddos (不传 project) + // 无 project-id → wire 上不该凭空出现该字段。 + name: "generic 无 project-id (mysql/uddos)", + build: func(t *testing.T) request.Common { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{"Action": "X"}); err != nil { + t.Fatal(err) + } + return gr + }, + wantWire: "", wantSet: false, + }, + { + // 绝大多数产品:typed SDK 请求 —— 历史行为,必须不变。 + name: "typed request + id/name (绝大多数产品)", + build: func(t *testing.T) request.Common { + req := &request.CommonBase{} + if err := req.SetProjectId(idName); err != nil { + t.Fatal(err) + } + return req + }, + wantWire: bare, wantSet: true, + }, + { + name: "typed request + bare id", + build: func(t *testing.T) request.Common { + req := &request.CommonBase{} + if err := req.SetProjectId(bare); err != nil { + t.Fatal(err) + } + return req + }, + wantWire: bare, wantSet: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := normalizeProjectID(tt.build(t)) + if err != nil { + t.Fatalf("normalizeProjectID: %v", err) + } + got, ok := projectIDOnWire(t, out) + if ok != tt.wantSet { + t.Fatalf("ProjectId present on wire = %v, want %v (got %q)", ok, tt.wantSet, got) + } + if got != tt.wantWire { + t.Errorf("ProjectId on wire = %q, want %q", got, tt.wantWire) + } + }) + } +} + +// 归一化不得殃及 payload 里的其它字段 —— SetPayload 是整表替换,回归保护。 +func TestNormalizeProjectIDLeavesOtherPayloadFieldsIntact(t *testing.T) { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{ + "Action": "CreateUMongoDBReplSet", + "ProjectId": "org-x/MyProject", + "Region": "cn-bj2", + "Zone": "cn-bj2-02", + "Name": "my/instance/with/slashes", // 业务字段里的 "/" 绝不能被 pick + "DiskSpace": 100, // int 必须仍是 int(JSON 编码器依赖它) + "IsMemoryDB": true, + }); err != nil { + t.Fatal(err) + } + out, err := normalizeProjectID(gr) + if err != nil { + t.Fatal(err) + } + payload := out.(request.GenericRequest).GetPayload() + if payload["ProjectId"] != "org-x" { + t.Errorf("ProjectId = %v, want org-x", payload["ProjectId"]) + } + if payload["Name"] != "my/instance/with/slashes" { + t.Errorf("business field with slashes was mangled: %v", payload["Name"]) + } + if payload["DiskSpace"] != 100 { + t.Errorf("DiskSpace = %#v, want int 100 (type must survive for JSON encoder)", payload["DiskSpace"]) + } + if payload["IsMemoryDB"] != true { + t.Errorf("IsMemoryDB = %#v, want bool true", payload["IsMemoryDB"]) + } + if payload["Action"] != "CreateUMongoDBReplSet" || payload["Region"] != "cn-bj2" || payload["Zone"] != "cn-bj2-02" { + t.Errorf("common fields changed: %v", payload) + } +} From f34f7413b74bf4365b3cab99004227456e01def3 Mon Sep 17 00:00:00 2001 From: Episkey Date: Tue, 14 Jul 2026 22:55:24 -0700 Subject: [PATCH 2/2] fix(platform): strip oauth signature params from JSON request bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit injector 此前只对 form-urlencoded body 剥离 SDK 无条件附加的 Signature/PublicKey (Credential.Apply 即使空密钥也会算出签名),JSON body 走不到剥离分支。 平台不变式(见 buildCredential 注释)要求「一个请求只携带一种凭据机制」。JSON 编码器此前不可达,故该约束在 JSON 路径上从未被满足;products/pgsql(#127) 是第一个 走 JSON 编码器的产品(UPgSQL 网关无法把 form 的字符串 "100" unmarshal 进 Go 的 *int,RetCode 214001,故 SetEncoder 换成 NewJSONEncoder)。 这不是在修一个当前故障。2026-07-14 对 api.ucloud.cn 实测确认:oauth + JSON body 携带 Signature 时网关照常返回 RetCode 0 —— 网关不从 JSON body 读签名参数,看到 Bearer 即走 Bearer 鉴权,pgsql 在 OAuth 下今天可正常使用。本改动是加固:把不变式 在 JSON 路径上补齐,不再依赖网关当前的宽容(那是实现细节而非契约,一旦收紧就会 变成难查的 171)。 改法与 form 分支同构:按 Content-Type 精确分派,不认识的一律不碰 body (url.ParseQuery 对 JSON 往往"成功",盲目重编码会毁掉 body —— 这正是原先只处理 form 的理由,现在升级为精确分派)。 回归保护:aksk + JSON body 逐字节不变(AK/SK 的签名活在 body 里)、oauth + form 行为与历史一致、不认识的 Content-Type 不碰 body。 --- cmd/internal/platform/client.go | 24 ++++- cmd/internal/platform/client_test.go | 140 +++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/cmd/internal/platform/client.go b/cmd/internal/platform/client.go index 6a7e918d2d..00410e08c4 100644 --- a/cmd/internal/platform/client.go +++ b/cmd/internal/platform/client.go @@ -29,9 +29,11 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler return req, err } if credConfig.AuthMode == AuthModeOAuth { - // 仅对 form-urlencoded body 剥离(JSON 编码器虽当前不可达,但 - // url.ParseQuery 对 JSON 往往"成功",重编码会悄悄毁掉 body) - if req.GetHeaderMap()[http.HeaderNameContentType] == http.MimeFormURLEncoded { + // 按 Content-Type 分派剥离:编码器决定 body 形态,剥离方式必须与之配对。 + // 不能只按一种形态盲剥 —— url.ParseQuery 对 JSON 往往"成功",重编码会 + // 悄悄毁掉 body;不认识的 Content-Type 一律不碰 body。 + switch req.GetHeaderMap()[http.HeaderNameContentType] { + case http.MimeFormURLEncoded: vals, err := url.ParseQuery(string(req.GetRequestBody())) if err != nil { // 剥不掉就明确失败:客户端报错优于网关 171 @@ -42,6 +44,22 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler if err := req.SetRequestBody([]byte(vals.Encode())); err != nil { return req, err } + case http.MimeJSON: + // JSONEncoder 把 cred.Apply 附加的 Signature/PublicKey 放在 body 顶层 + // (SDK ucloud/request/encoder_json.go),与 form 分支同构剥离。 + var payload map[string]interface{} + if err := json.Unmarshal(req.GetRequestBody(), &payload); err != nil { + return req, fmt.Errorf("strip signature params from oauth json request failed: %w", err) + } + delete(payload, "Signature") + delete(payload, "PublicKey") + bs, err := json.Marshal(payload) + if err != nil { + return req, fmt.Errorf("re-encode oauth json request failed: %w", err) + } + if err := req.SetRequestBody(bs); err != nil { + return req, err + } } if credConfig.AccessToken != "" { if err := req.SetHeader("Authorization", "Bearer "+credConfig.AccessToken); err != nil { diff --git a/cmd/internal/platform/client_test.go b/cmd/internal/platform/client_test.go index fdf114efc9..9b5b023550 100644 --- a/cmd/internal/platform/client_test.go +++ b/cmd/internal/platform/client_test.go @@ -13,6 +13,8 @@ import ( uhttp "github.com/ucloud/ucloud-sdk-go/private/protocol/http" "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" ) func injectorHeaders(t *testing.T, cred *CredentialConfig) map[string]string { @@ -61,6 +63,144 @@ func TestInjectorOAuthEmptyToken(t *testing.T) { } } +// injectBody 把 body 以指定 Content-Type 过一遍 injector,返回处理后的请求。 +func injectBody(t *testing.T, cred *CredentialConfig, contentType, body string) *uhttp.HttpRequest { + t.Helper() + req := uhttp.NewHttpRequest() + if err := req.SetHeader(uhttp.HeaderNameContentType, contentType); err != nil { + t.Fatal(err) + } + if err := req.SetRequestBody([]byte(body)); err != nil { + t.Fatal(err) + } + out, err := newCredHeaderInjector(cred)(nil, req) + if err != nil { + t.Fatal(err) + } + return out +} + +// oauth + JSON body:剥离 Signature/PublicKey,其余字段与 Content-Type 原样保留。 +// JSON 编码器此前不可达;products/pgsql(#127) 是第一个走这条路的产品(UPgSQL 网关 +// 无法把 form 的字符串 "100" unmarshal 进 Go 的 *int,RetCode 214001,故切 JSONEncoder)。 +func TestInjectorOAuthStripsJSONSignature(t *testing.T) { + body := `{"Action":"ListUPgSQLParamTemplate","Count":100,"PublicKey":"pub","Region":"cn-bj2","Signature":"deadbeef"}` + out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok123"}, uhttp.MimeJSON, body) + + var got map[string]interface{} + if err := json.Unmarshal(out.GetRequestBody(), &got); err != nil { + t.Fatalf("body is not valid json after strip: %v", err) + } + if _, ok := got["Signature"]; ok { + t.Error("Signature must be stripped from oauth json body") + } + if _, ok := got["PublicKey"]; ok { + t.Error("PublicKey must be stripped from oauth json body") + } + if got["Action"] != "ListUPgSQLParamTemplate" || got["Region"] != "cn-bj2" { + t.Errorf("business fields must survive untouched, got %v", got) + } + // int 必须仍是 JSON number —— 产品切 JSONEncoder 的全部意义就在于此, + // 剥离过程若把它变回字符串就重新踩回 214001。 + if got["Count"] != float64(100) { + t.Errorf("Count = %#v, want JSON number 100", got["Count"]) + } + if out.GetHeaderMap()[uhttp.HeaderNameContentType] != uhttp.MimeJSON { + t.Error("Content-Type must stay application/json") + } + if out.GetHeaderMap()["Authorization"] != "Bearer tok123" { + t.Error("Bearer must still be injected for json body") + } +} + +// CRITICAL 回归:非 oauth(aksk)模式下 JSON body 必须逐字节不变 —— +// AK/SK 路径的签名就活在 body 里,碰一下就验签失败。 +func TestInjectorAkskJSONBodyUntouched(t *testing.T) { + body := `{"Action":"X","PublicKey":"pub","Signature":"deadbeef"}` + out := injectBody(t, &CredentialConfig{PublicKey: "pub", PrivateKey: "pri"}, uhttp.MimeJSON, body) + if string(out.GetRequestBody()) != body { + t.Errorf("aksk json body must be byte-identical\n got: %s\nwant: %s", out.GetRequestBody(), body) + } +} + +// CRITICAL 回归:oauth + form body 行为与历史完全一致(本次只加分支,不动 form)。 +func TestInjectorOAuthFormUnchanged(t *testing.T) { + body := "Action=X&PublicKey=pub&Region=cn-bj2&Signature=deadbeef" + out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}, uhttp.MimeFormURLEncoded, body) + vals, err := url.ParseQuery(string(out.GetRequestBody())) + if err != nil { + t.Fatal(err) + } + if vals.Has("Signature") || vals.Has("PublicKey") { + t.Errorf("form signature params must be stripped, got %s", out.GetRequestBody()) + } + if vals.Get("Action") != "X" || vals.Get("Region") != "cn-bj2" { + t.Errorf("form business fields changed: %s", out.GetRequestBody()) + } +} + +// 不认识的 Content-Type:不碰 body(盲目重编码会毁掉它),但 Bearer 照常注入。 +func TestInjectorOAuthUnknownContentTypeBodyUntouched(t *testing.T) { + body := `deadbeef` + out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}, "application/xml", body) + if string(out.GetRequestBody()) != body { + t.Errorf("unknown content-type body must be untouched, got %s", out.GetRequestBody()) + } + if out.GetHeaderMap()["Authorization"] != "Bearer tok" { + t.Error("Bearer must still be injected for unknown content-type") + } +} + +// 端到端:真实 SDK JSONEncoder + 真实 oauth 凭据,确认 +// (a) SDK 即使凭据为空也会附加 Signature(这正是必须剥离的原因); +// (b) injector 之后 body 内再无签名参数,只剩 Bearer 一种凭据机制。 +func TestInjectorOAuthJSONEndToEndWithSDKEncoder(t *testing.T) { + credConfig := &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"} + cfg := ucloud.NewConfig() + cred := BuildCredentialFrom(credConfig) + + req := &request.CommonBase{} + if err := req.SetAction("ListUPgSQLParamTemplate"); err != nil { + t.Fatal(err) + } + if err := req.SetRegion("cn-bj2"); err != nil { + t.Fatal(err) + } + httpReq, err := request.NewJSONEncoder(&cfg, cred).Encode(req) + if err != nil { + t.Fatal(err) + } + + var before map[string]interface{} + if err := json.Unmarshal(httpReq.GetRequestBody(), &before); err != nil { + t.Fatal(err) + } + if _, ok := before["Signature"]; !ok { + t.Fatal("premise broken: SDK JSONEncoder no longer attaches Signature for empty credential") + } + + out, err := newCredHeaderInjector(credConfig)(nil, httpReq) + if err != nil { + t.Fatal(err) + } + var after map[string]interface{} + if err := json.Unmarshal(out.GetRequestBody(), &after); err != nil { + t.Fatal(err) + } + if _, ok := after["Signature"]; ok { + t.Error("Signature survived the injector on a real SDK-encoded json body") + } + if _, ok := after["PublicKey"]; ok { + t.Error("PublicKey survived the injector on a real SDK-encoded json body") + } + if after["Action"] != "ListUPgSQLParamTemplate" { + t.Errorf("Action lost: %v", after) + } + if out.GetHeaderMap()["Authorization"] != "Bearer tok" { + t.Error("Bearer missing after injector") + } +} + // recordedRequest 记录业务请求实际携带的 header 与全部参数(query + form 合并) type recordedRequest struct { header http.Header