From d6c7ffff889c315535a20228af25adc0db83036b Mon Sep 17 00:00:00 2001 From: tytv2 Date: Wed, 1 Jul 2026 23:18:05 +0700 Subject: [PATCH] security(cli): warn on --endpoint-url outside trusted vngcloud.vn domain (SEC-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grn authenticates against the real IAM and sends the resulting reusable bearer token to whatever host --endpoint-url names — so a mistyped or malicious host captures a token it can replay against the real API. Unlike AWS SigV4 (signed per request/host), this bearer is replayable. Add cli.IsTrustedEndpoint / WarnIfUntrustedEndpoint and call it from the VKS and vServer client builders: when --endpoint-url resolves to a host outside *.vngcloud.vn, print a stderr warning that a bearer token will be sent there. Warns only; does not block (custom/test endpoints still work). Co-Authored-By: Claude Opus 4.8 --- .../enhancement-core-zf6kl82i.json | 5 ++ go/internal/cli/client.go | 1 + go/internal/cli/endpoint.go | 46 +++++++++++++++++++ go/internal/cli/endpoint_test.go | 21 +++++++++ go/internal/vserverclient/client.go | 2 + 5 files changed, 75 insertions(+) create mode 100644 .changes/next-release/enhancement-core-zf6kl82i.json create mode 100644 go/internal/cli/endpoint.go create mode 100644 go/internal/cli/endpoint_test.go diff --git a/.changes/next-release/enhancement-core-zf6kl82i.json b/.changes/next-release/enhancement-core-zf6kl82i.json new file mode 100644 index 0000000..4fa1a93 --- /dev/null +++ b/.changes/next-release/enhancement-core-zf6kl82i.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "core", + "description": "Warn when --endpoint-url points outside the trusted vngcloud.vn domain, since grn sends a replayable IAM bearer token with every request (SEC-08). The warning does not block the request" +} diff --git a/go/internal/cli/client.go b/go/internal/cli/client.go index b7be48d..27f8138 100644 --- a/go/internal/cli/client.go +++ b/go/internal/cli/client.go @@ -40,6 +40,7 @@ func NewClient(cmd *cobra.Command, serviceName string) (*client.GreenodeClient, var baseURL string if endpointURL != "" { + WarnIfUntrustedEndpoint(endpointURL) baseURL = endpointURL } else { baseURL, err = cfg.GetEndpoint(serviceName) diff --git a/go/internal/cli/endpoint.go b/go/internal/cli/endpoint.go new file mode 100644 index 0000000..648d3d4 --- /dev/null +++ b/go/internal/cli/endpoint.go @@ -0,0 +1,46 @@ +package cli + +import ( + "fmt" + "net/url" + "os" + "strings" +) + +// trustedEndpointSuffix is the domain grn's own services live under. Requests to +// hosts outside it are flagged because grn sends a reusable IAM bearer token +// with every request (see WarnIfUntrustedEndpoint). +const trustedEndpointSuffix = ".vngcloud.vn" + +// IsTrustedEndpoint reports whether endpointURL targets a host within the +// trusted vngcloud.vn domain. An empty value means no --endpoint-url override +// was given (the built-in region endpoint is used), which is trusted. +func IsTrustedEndpoint(endpointURL string) bool { + if endpointURL == "" { + return true + } + u, err := url.Parse(endpointURL) + if err != nil { + return false + } + host := u.Hostname() + if host == "" { + return false + } + return host == "vngcloud.vn" || strings.HasSuffix(host, trustedEndpointSuffix) +} + +// WarnIfUntrustedEndpoint prints a security warning to stderr when endpointURL +// points outside the trusted vngcloud.vn domain. grn authenticates against the +// real IAM and sends the resulting reusable bearer token to whatever host +// --endpoint-url names, so a malicious or mistyped host can capture and replay +// that token. This warns; it does not block. +func WarnIfUntrustedEndpoint(endpointURL string) { + if IsTrustedEndpoint(endpointURL) { + return + } + u, _ := url.Parse(endpointURL) + fmt.Fprintf(os.Stderr, + "Warning: --endpoint-url %q is outside the trusted %s domain. grn will send your IAM bearer token to this host, and a bearer token can be replayed. Only use endpoints you trust.\n", + u.Hostname(), strings.TrimPrefix(trustedEndpointSuffix, ".")) +} diff --git a/go/internal/cli/endpoint_test.go b/go/internal/cli/endpoint_test.go new file mode 100644 index 0000000..63e188b --- /dev/null +++ b/go/internal/cli/endpoint_test.go @@ -0,0 +1,21 @@ +package cli + +import "testing" + +func TestIsTrustedEndpoint(t *testing.T) { + cases := map[string]bool{ + "": true, // no override -> built-in endpoint + "https://vks.api.vngcloud.vn": true, + "https://hcm-3.api.vngcloud.vn/x": true, + "https://vngcloud.vn": true, + "http://attacker.com": false, + "https://evil.vngcloud.vn.attacker.com": false, // suffix must be a real domain boundary + "http://localhost:8080": false, + "not-a-url ::::": false, + } + for in, want := range cases { + if got := IsTrustedEndpoint(in); got != want { + t.Errorf("IsTrustedEndpoint(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/go/internal/vserverclient/client.go b/go/internal/vserverclient/client.go index f93f000..575709d 100644 --- a/go/internal/vserverclient/client.go +++ b/go/internal/vserverclient/client.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/vngcloud/greennode-cli/internal/auth" + "github.com/vngcloud/greennode-cli/internal/cli" "github.com/vngcloud/greennode-cli/internal/client" "github.com/vngcloud/greennode-cli/internal/config" "github.com/vngcloud/greennode-cli/internal/formatter" @@ -37,6 +38,7 @@ func BuildClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, er var baseURL string if endpointURL != "" { + cli.WarnIfUntrustedEndpoint(endpointURL) baseURL = endpointURL } else { baseURL, err = cfg.GetEndpoint("vserver")