Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-core-5xawlnap.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 2 additions & 0 deletions go/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var (
CLIReadTimeout int
CLIConnectTimeout int
Color string
AllowUntrusted bool
)

var rootCmd = &cobra.Command{
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions go/internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions go/internal/cli/endpoint.go
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions go/internal/cli/endpoint_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
6 changes: 6 additions & 0 deletions go/internal/vserverclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
Loading