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 明文传输密钥和数据。不开放支付、账号权限管理及敏感密钥读取。