From 71f7751754fae04383e1e34c809401a11dcf656b Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Mon, 7 Sep 2026 17:25:55 +0800
Subject: [PATCH] fix(client-api): support HTTP and HTTPS by default (#237)
---
server/app/goauto/clientapi/gateway.go | 28 +----
server/app/goauto/clientapi/gateway_test.go | 103 ++++++++----------
.../app/goauto/clientapi/integration_test.go | 38 ++++---
server/app/goauto/clientapi/routes.go | 2 +-
web/src/views/goauto/client-keys/index.vue | 2 +-
5 files changed, 71 insertions(+), 102 deletions(-)
diff --git a/server/app/goauto/clientapi/gateway.go b/server/app/goauto/clientapi/gateway.go
index 8a786e3..967604d 100644
--- a/server/app/goauto/clientapi/gateway.go
+++ b/server/app/goauto/clientapi/gateway.go
@@ -7,9 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
- "net"
"net/http"
- "os"
"strings"
"time"
@@ -20,26 +18,10 @@ import (
"gorm.io/gorm"
)
-// Only TLS or explicitly trusted loopback reverse proxies may carry a key.
-// The Agent's insecure-HTTP exception does not apply to client credentials.
-func secure(r *http.Request) bool {
- if r.TLS != nil {
- return true
- }
- host, _, err := net.SplitHostPort(r.RemoteAddr)
- if err != nil {
- return false
- }
- ip := net.ParseIP(host)
- return ip != nil && ip.IsLoopback() && os.Getenv("GOAUTO_TRUST_FORWARDED_PROTO") == "true" && r.Header.Get("X-Forwarded-Proto") == "https"
-}
-
-func RequireHTTPS(c *gin.Context) {
+// Both HTTP and HTTPS are supported by explicit operator decision (#237).
+// Transport does not grant identity or permissions; secrets remain non-cacheable.
+func NoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
- if !secure(c.Request) {
- c.AbortWithStatusJSON(426, gin.H{"code": 426, "message": "客户端密钥管理仅允许 HTTPS"})
- return
- }
c.Next()
}
func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
@@ -48,10 +30,6 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
deny := func(status int, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
- if !secure(c.Request) {
- deny(426, "客户端密钥仅允许 HTTPS")
- return
- }
// Never accept a credential supplied through a URL or cookie fallback.
for k := range c.Request.URL.Query() {
if sensitive(k) || strings.EqualFold(k, "token") {
diff --git a/server/app/goauto/clientapi/gateway_test.go b/server/app/goauto/clientapi/gateway_test.go
index 22a4677..a118056 100644
--- a/server/app/goauto/clientapi/gateway_test.go
+++ b/server/app/goauto/clientapi/gateway_test.go
@@ -10,7 +10,6 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
- "net/http"
"net/http/httptest"
"strings"
"testing"
@@ -54,38 +53,40 @@ func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
}
}
for _, e := range routes {
- grant := clientkey.Grant{Module: e.Module}
- v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
- if err != nil {
- t.Fatal(err)
- }
- path := e.Path
- for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
- path = strings.ReplaceAll(path, param, "1")
- }
- req := httptest.NewRequest(e.Method, "https://example.test/api/client/v1"+path, nil)
- req.Header.Set("Authorization", "Bearer "+token)
- rr := httptest.NewRecorder()
- router.ServeHTTP(rr, req)
- want := 200
- if e.Capability != "read" {
- want = 403
- }
- if rr.Code != want {
- t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
- }
- if e.Capability == "write" {
- grant.Write = true
- } else if e.Capability != "read" {
- grant.Actions = []string{e.Capability}
- }
- if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
- t.Fatal(err)
- }
- rr = httptest.NewRecorder()
- router.ServeHTTP(rr, req.Clone(context.Background()))
- if rr.Code != 200 {
- t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
+ for _, scheme := range []string{"http", "https"} {
+ grant := clientkey.Grant{Module: e.Module}
+ v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := e.Path
+ for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
+ path = strings.ReplaceAll(path, param, "1")
+ }
+ req := httptest.NewRequest(e.Method, scheme+"://example.test/api/client/v1"+path, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ want := 200
+ if e.Capability != "read" {
+ want = 403
+ }
+ if rr.Code != want {
+ t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
+ }
+ if e.Capability == "write" {
+ grant.Write = true
+ } else if e.Capability != "read" {
+ grant.Actions = []string{e.Capability}
+ }
+ if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
+ t.Fatal(err)
+ }
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, req.Clone(context.Background()))
+ if rr.Code != 200 {
+ t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
+ }
}
}
}
@@ -118,17 +119,22 @@ func TestRevocationTransportRedactionAndAudit(t *testing.T) {
return rr
}
path := "/api/client/v1/pdd-products"
- if send(false, token, path).Code != 426 || send(true, "jwt", path).Code != 401 || send(true, token, path+"?token=invalid").Code != 400 {
- t.Fatal("transport or credential fallback accepted")
- }
- rr := send(true, token, path)
- if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
- t.Fatal("redaction failed")
+ for _, tlsOn := range []bool{false, true} {
+ if send(tlsOn, "jwt", path).Code != 401 || send(tlsOn, "", path).Code != 401 || send(tlsOn, token, path+"?token=invalid").Code != 400 {
+ t.Fatal("credential fallback accepted")
+ }
+ rr := send(tlsOn, token, path)
+ if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
+ t.Fatal("redaction failed")
+ }
+ if rr.Header().Get("Cache-Control") != "no-store" || rr.Header().Get("X-Client-Request-Id") == "" {
+ t.Fatal("cache or audit headers missing")
+ }
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil {
t.Fatal(err)
}
- if send(true, token, path).Code != 401 || calls != 1 {
+ if send(true, token, path).Code != 401 || send(false, token, path).Code != 401 || calls != 2 {
t.Fatal("revocation not immediate")
}
var logs []clientkey.Audit
@@ -157,20 +163,3 @@ func TestAuditUnavailableDoesNotExecute(t *testing.T) {
t.Fatal("executed without audit")
}
}
-func TestForwardedProtoOnlyTrustedLoopback(t *testing.T) {
- t.Setenv("GOAUTO_TRUST_FORWARDED_PROTO", "true")
- r := httptest.NewRequest(http.MethodGet, "http://example.test", nil)
- r.Header.Set("X-Forwarded-Proto", "https")
- r.RemoteAddr = "192.0.2.1:1234"
- if secure(r) {
- t.Fatal("untrusted forwarded header")
- }
- r.RemoteAddr = "127.0.0.1:1234"
- if !secure(r) {
- t.Fatal("trusted proxy rejected")
- }
- t.Setenv("GOAUTO_TRUST_FORWARDED_PROTO", "false")
- if secure(r) {
- t.Fatal("implicit proxy trust")
- }
-}
diff --git a/server/app/goauto/clientapi/integration_test.go b/server/app/goauto/clientapi/integration_test.go
index 496e95a..e9358f9 100644
--- a/server/app/goauto/clientapi/integration_test.go
+++ b/server/app/goauto/clientapi/integration_test.go
@@ -56,24 +56,26 @@ func TestManagementRequiresAdminNotClientIdentity(t *testing.T) {
db, s := fixture(t)
h := clientkey.Handler{DB: db, Modules: s.Modules}
for _, role := range []string{"admin", "purchaser", "client", ""} {
- router := gin.New()
- router.Use(func(c *gin.Context) {
- c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
- c.Next()
- })
- router.POST("/keys", middleware.RequireRoleKey("admin"), RequireHTTPS, h.Create)
- req := httptest.NewRequest("POST", "https://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
- rr := httptest.NewRecorder()
- router.ServeHTTP(rr, req)
- want := 403
- if role == "admin" {
- want = 200
- }
- if rr.Code != want {
- t.Fatalf("role %q status %d", role, rr.Code)
- }
- if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
- t.Fatal("one-time secret cacheable")
+ for _, scheme := range []string{"http", "https"} {
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
+ c.Next()
+ })
+ router.POST("/keys", middleware.RequireRoleKey("admin"), NoStore, h.Create)
+ req := httptest.NewRequest("POST", scheme+"://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ want := 403
+ if role == "admin" {
+ want = 200
+ }
+ if rr.Code != want {
+ t.Fatalf("role %q status %d", role, rr.Code)
+ }
+ if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("one-time secret cacheable")
+ }
}
}
}
diff --git a/server/app/goauto/clientapi/routes.go b/server/app/goauto/clientapi/routes.go
index 63e02f2..0598e90 100644
--- a/server/app/goauto/clientapi/routes.go
+++ b/server/app/goauto/clientapi/routes.go
@@ -158,7 +158,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
routes := Inventory()
catalog := Catalog(routes)
h := clientkey.Handler{Modules: catalog}
- management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), RequireHTTPS)
+ management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), NoStore)
management.GET("", h.List)
management.GET("/modules", h.Catalog)
management.POST("", h.Create)
diff --git a/web/src/views/goauto/client-keys/index.vue b/web/src/views/goauto/client-keys/index.vue
index 7e77bfa..03652ab 100644
--- a/web/src/views/goauto/client-keys/index.vue
+++ b/web/src/views/goauto/client-keys/index.vue
@@ -14,7 +14,7 @@
客户端地址:/api/client/v1。仅允许 HTTPS;不开放支付、账号权限管理及敏感密钥读取。
+客户端地址:/api/client/v1。支持 HTTP / HTTPS;HTTP 明文传输密钥和数据。不开放支付、账号权限管理及敏感密钥读取。