diff --git a/.changes/next-release/enhancement-core-5xawlnap.json b/.changes/next-release/enhancement-core-5xawlnap.json new file mode 100644 index 0000000..c7bd59a --- /dev/null +++ b/.changes/next-release/enhancement-core-5xawlnap.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "core", + "description": "Guard --endpoint-url against sending the IAM bearer token to untrusted hosts (SEC-08): hosts outside vngcloud.vn/greenode.ai are warned over TLS, and blocked when there is no TLS protection (plain http or --no-verify-ssl) unless --allow-untrusted-endpoint is set" +} diff --git a/go/cmd/root.go b/go/cmd/root.go index 6277601..402fd75 100644 --- a/go/cmd/root.go +++ b/go/cmd/root.go @@ -25,6 +25,7 @@ var ( CLIReadTimeout int CLIConnectTimeout int Color string + AllowUntrusted bool ) var rootCmd = &cobra.Command{ @@ -60,6 +61,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&Query, "query", "", "JMESPath query to filter output") rootCmd.PersistentFlags().StringVar(&EndpointURL, "endpoint-url", "", "Override the service endpoint URL") rootCmd.PersistentFlags().BoolVar(&NoVerifySSL, "no-verify-ssl", false, "Disable SSL certificate verification") + rootCmd.PersistentFlags().BoolVar(&AllowUntrusted, "allow-untrusted-endpoint", false, "Allow --endpoint-url to a host outside vngcloud.vn/greenode.ai without TLS protection (sends a bearer token there)") rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "Enable debug logging") rootCmd.PersistentFlags().IntVar(&CLIReadTimeout, "cli-read-timeout", 30, "HTTP read timeout in seconds") rootCmd.PersistentFlags().IntVar(&CLIConnectTimeout, "cli-connect-timeout", 30, "HTTP connect timeout in seconds") diff --git a/go/internal/cli/client.go b/go/internal/cli/client.go index b7be48d..b31a243 100644 --- a/go/internal/cli/client.go +++ b/go/internal/cli/client.go @@ -22,9 +22,14 @@ func NewClient(cmd *cobra.Command, serviceName string) (*client.GreenodeClient, endpointURL, _ := cmd.Flags().GetString("endpoint-url") noVerifySSL, _ := cmd.Flags().GetBool("no-verify-ssl") debug, _ := cmd.Flags().GetBool("debug") + allowUntrusted, _ := cmd.Flags().GetBool("allow-untrusted-endpoint") connectTimeout, _ := cmd.Flags().GetInt("cli-connect-timeout") readTimeout, _ := cmd.Flags().GetInt("cli-read-timeout") + if err := CheckEndpoint(endpointURL, noVerifySSL, allowUntrusted); err != nil { + return nil, err + } + cfg, err := config.LoadConfig(profile) if err != nil { return nil, err diff --git a/go/internal/cli/endpoint.go b/go/internal/cli/endpoint.go new file mode 100644 index 0000000..0b7c5a9 --- /dev/null +++ b/go/internal/cli/endpoint.go @@ -0,0 +1,70 @@ +package cli + +import ( + "fmt" + "net/url" + "os" + "strings" +) + +// trustedEndpointDomains are the domains grn's own services live under. A +// request to a host outside these is flagged because grn sends a reusable IAM +// bearer token with every request (see CheckEndpoint / SEC-08). +var trustedEndpointDomains = []string{"vngcloud.vn", "greenode.ai"} + +// IsTrustedEndpoint reports whether endpointURL targets a host within a trusted +// 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 + } + for _, d := range trustedEndpointDomains { + if host == d || strings.HasSuffix(host, "."+d) { + return true + } + } + return false +} + +// CheckEndpoint enforces the endpoint-safety policy for --endpoint-url. grn +// authenticates against the real IAM and sends the resulting reusable bearer +// token to whatever host --endpoint-url names, so: +// - trusted host (or no override): allowed silently. +// - untrusted host over TLS (https, cert verified): a warning is printed. +// - untrusted host without TLS protection (plain http, or --no-verify-ssl): +// blocked with an error unless allowUntrusted is set, because the token can +// be captured (MITM) and replayed. +// +// It returns a non-nil error only for the blocked case. +func CheckEndpoint(endpointURL string, noVerifySSL, allowUntrusted bool) error { + if IsTrustedEndpoint(endpointURL) { + return nil + } + u, _ := url.Parse(endpointURL) + host := u.Hostname() + + noTLS := noVerifySSL || strings.EqualFold(u.Scheme, "http") + if noTLS && !allowUntrusted { + reason := "plain HTTP" + if noVerifySSL { + reason = "--no-verify-ssl" + } + return fmt.Errorf( + "refusing to send your IAM bearer token to untrusted host %q over an unprotected connection (%s): the token could be captured and replayed. Re-run with --allow-untrusted-endpoint if you really intend this", + host, reason) + } + + fmt.Fprintf(os.Stderr, + "Warning: --endpoint-url %q is outside the trusted domains (%s). grn will send your IAM bearer token to this host, and a bearer token can be replayed. Only use endpoints you trust.\n", + host, strings.Join(trustedEndpointDomains, ", ")) + return nil +} diff --git a/go/internal/cli/endpoint_test.go b/go/internal/cli/endpoint_test.go new file mode 100644 index 0000000..006df34 --- /dev/null +++ b/go/internal/cli/endpoint_test.go @@ -0,0 +1,49 @@ +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, + "https://api.greenode.ai": true, + "https://greenode.ai": true, + "http://attacker.com": false, + "https://evil.vngcloud.vn.attacker.com": false, // suffix must be a real domain boundary + "https://notgreenode.ai": false, // must match on a dot 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) + } + } +} + +func TestCheckEndpointPolicy(t *testing.T) { + cases := []struct { + name string + endpoint string + noVerifySSL bool + allowUntrusted bool + wantBlocked bool + }{ + {"trusted https", "https://vks.api.vngcloud.vn", false, false, false}, + {"trusted greenode", "https://api.greenode.ai", false, false, false}, + {"no override", "", false, false, false}, + {"untrusted https verified -> warn only", "https://custom.example.com", false, false, false}, + {"untrusted http -> block", "http://attacker.com", false, false, true}, + {"untrusted https + no-verify -> block", "https://attacker.com", true, false, true}, + {"untrusted http + allow -> warn", "http://attacker.com", false, true, false}, + {"untrusted https + no-verify + allow -> warn", "https://attacker.com", true, true, false}, + } + for _, tc := range cases { + err := CheckEndpoint(tc.endpoint, tc.noVerifySSL, tc.allowUntrusted) + if (err != nil) != tc.wantBlocked { + t.Errorf("%s: blocked=%v, want %v (err=%v)", tc.name, err != nil, tc.wantBlocked, err) + } + } +} diff --git a/go/internal/vserverclient/client.go b/go/internal/vserverclient/client.go index f93f000..441064d 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" @@ -19,9 +20,14 @@ func BuildClient(cmd *cobra.Command) (*client.GreenodeClient, *config.Config, er endpointURL, _ := cmd.Flags().GetString("endpoint-url") noVerifySSL, _ := cmd.Flags().GetBool("no-verify-ssl") debug, _ := cmd.Flags().GetBool("debug") + allowUntrusted, _ := cmd.Flags().GetBool("allow-untrusted-endpoint") connectTimeout, _ := cmd.Flags().GetInt("cli-connect-timeout") readTimeout, _ := cmd.Flags().GetInt("cli-read-timeout") + if err := cli.CheckEndpoint(endpointURL, noVerifySSL, allowUntrusted); err != nil { + return nil, nil, err + } + cfg, err := config.LoadConfig(profile) if err != nil { return nil, nil, err