From 532be53049457bf8992c868446691f1b663c545d Mon Sep 17 00:00:00 2001 From: chen21019 Date: Wed, 26 Aug 2026 13:28:41 +0800 Subject: [PATCH] security: bind Rancher requests to configured origin --- .../event-subscriber/events/listener.go | 31 ++++--- .../event-subscriber/events/security_url.go | 64 ++++++++++++++ .../events/security_url_test.go | 28 ++++++ third_party/go-rancher/v2/common.go | 34 +++++--- third_party/go-rancher/v2/security_url.go | 85 +++++++++++++++++++ .../go-rancher/v2/security_url_test.go | 32 +++++++ 6 files changed, 248 insertions(+), 26 deletions(-) create mode 100644 third_party/event-subscriber/events/security_url.go create mode 100644 third_party/event-subscriber/events/security_url_test.go create mode 100644 third_party/go-rancher/v2/security_url.go create mode 100644 third_party/go-rancher/v2/security_url_test.go diff --git a/third_party/event-subscriber/events/listener.go b/third_party/event-subscriber/events/listener.go index 3403a26..57d2622 100644 --- a/third_party/event-subscriber/events/listener.go +++ b/third_party/event-subscriber/events/listener.go @@ -4,12 +4,12 @@ import ( "bytes" "encoding/base64" "encoding/json" - "io/ioutil" + "fmt" + "io" "net/http" "net/url" "os" "strconv" - "strings" "time" "github.com/gorilla/websocket" @@ -68,8 +68,10 @@ func NewEventRouter(name string, priority int, apiURL string, accessKey string, } } - // TODO Get subscribe collection URL from API instead of hard coding - subscribeURL := strings.Replace(apiURL+"/subscribe", "http", "ws", -1) + subscribeURL, err := buildEventSubscriptionURL(apiURL) + if err != nil { + return nil, err + } return &EventRouter{ name: name, @@ -183,23 +185,28 @@ func (router *EventRouter) subscribeToEvents(subscribeURL string, accessKey stri dialer := &websocket.Dialer{} headers := http.Header{} headers.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(accessKey+":"+secretKey))) - subscribeURL = subscribeURL + "?" + data.Encode() - ws, resp, err := dialer.Dial(subscribeURL, headers) + target, err := validateEventSubscriptionURL(router.apiURL, subscribeURL, data) + if err != nil { + return nil, err + } + if !eventSubscriptionURLPattern.MatchString(target) { + return nil, fmt.Errorf("event subscription URL failed validation") + } + ws, resp, err := dialer.Dial(target, headers) if err != nil { + endpoint, _ := url.Parse(target) log.WithFields(log.Fields{ - "subscribeUrl": subscribeURL, + "subscribeEndpoint": endpoint.Scheme + "://" + endpoint.Host + endpoint.Path, }).Errorf("Error subscribing to events: %s", err) if resp != nil { log.WithFields(log.Fields{ - "status": resp.Status, - "statusCode": resp.StatusCode, - "responseHeaders": resp.Header, + "status": resp.Status, + "statusCode": resp.StatusCode, }).Error("Got error response") if resp.Body != nil { defer resp.Body.Close() - body, _ := ioutil.ReadAll(resp.Body) - log.Errorf("Error response: %s", body) + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) } } return nil, err diff --git a/third_party/event-subscriber/events/security_url.go b/third_party/event-subscriber/events/security_url.go new file mode 100644 index 0000000..f720a9b --- /dev/null +++ b/third_party/event-subscriber/events/security_url.go @@ -0,0 +1,64 @@ +package events + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +var eventSubscriptionURLPattern = regexp.MustCompile(`^wss?://(?:\[[0-9A-Fa-f:.%]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)(?::[0-9]{1,5})?(?:/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?(?:\?[A-Za-z0-9._~!$&'()*+,;=:@%/?-]*)?$`) + +func buildEventSubscriptionURL(apiURL string) (string, error) { + base, err := url.Parse(apiURL) + if err != nil || base.Opaque != "" || base.User != nil || base.Hostname() == "" { + return "", fmt.Errorf("event API URL must contain a valid origin") + } + switch strings.ToLower(base.Scheme) { + case "http": + base.Scheme = "ws" + case "https": + base.Scheme = "wss" + default: + return "", fmt.Errorf("unsupported event API URL scheme %q", base.Scheme) + } + base.Path = strings.TrimRight(base.Path, "/") + "/subscribe" + base.RawPath = "" + base.RawQuery = "" + base.Fragment = "" + return validateEventSubscriptionURL(apiURL, base.String(), nil) +} + +func validateEventSubscriptionURL(apiURL, candidateURL string, data url.Values) (string, error) { + if !eventSubscriptionURLPattern.MatchString(candidateURL) { + return "", fmt.Errorf("event subscription URL has an unsupported format") + } + base, err := url.Parse(apiURL) + if err != nil || base.Opaque != "" || base.User != nil || base.Hostname() == "" { + return "", fmt.Errorf("event API URL must contain a valid origin") + } + target, err := url.Parse(candidateURL) + if err != nil || target.Opaque != "" || target.User != nil || target.Hostname() == "" { + return "", fmt.Errorf("event subscription URL must contain a valid origin") + } + expectedScheme := "ws" + if strings.EqualFold(base.Scheme, "https") { + expectedScheme = "wss" + } else if !strings.EqualFold(base.Scheme, "http") { + return "", fmt.Errorf("unsupported event API URL scheme %q", base.Scheme) + } + if !strings.EqualFold(target.Scheme, expectedScheme) || !strings.EqualFold(target.Host, base.Host) { + return "", fmt.Errorf("event subscription URL crosses the configured origin") + } + query := target.Query() + for key, values := range data { + for _, value := range values { + query.Add(key, value) + } + } + target.RawQuery = query.Encode() + if !eventSubscriptionURLPattern.MatchString(target.String()) { + return "", fmt.Errorf("event subscription URL failed validation") + } + return target.String(), nil +} diff --git a/third_party/event-subscriber/events/security_url_test.go b/third_party/event-subscriber/events/security_url_test.go new file mode 100644 index 0000000..f7706d3 --- /dev/null +++ b/third_party/event-subscriber/events/security_url_test.go @@ -0,0 +1,28 @@ +package events + +import ( + "net/url" + "testing" +) + +func TestEventSubscriptionURLBindsConfiguredOrigin(t *testing.T) { + base, err := buildEventSubscriptionURL("https://api.example.test/v2-beta") + if err != nil { + t.Fatal(err) + } + query := url.Values{"eventNames": {"ping", "host.change"}} + target, err := validateEventSubscriptionURL("https://api.example.test/v2-beta", base, query) + if err != nil { + t.Fatal(err) + } + if !eventSubscriptionURLPattern.MatchString(target) { + t.Fatalf("validated target did not match the request policy: %s", target) + } + if _, err := validateEventSubscriptionURL( + "https://api.example.test/v2-beta", + "wss://metadata.example.test/v2-beta/subscribe", + nil, + ); err == nil { + t.Fatal("cross-origin event subscription URL was accepted") + } +} diff --git a/third_party/go-rancher/v2/common.go b/third_party/go-rancher/v2/common.go index b7baa15..faa77e7 100644 --- a/third_party/go-rancher/v2/common.go +++ b/third_party/go-rancher/v2/common.go @@ -148,7 +148,7 @@ func setupRancherBaseClient(rancherClient *RancherBaseClientImpl, opts *ClientOp opts.Timeout = time.Second * 10 } client := &http.Client{Timeout: opts.Timeout} - req, err := http.NewRequest("GET", opts.Url, nil) + req, err := newRancherRequest(opts.Url, "GET", opts.Url, nil) if err != nil { return err } @@ -172,11 +172,11 @@ func setupRancherBaseClient(rancherClient *RancherBaseClientImpl, opts *ClientOp } if schemasUrls != opts.Url { - req, err = http.NewRequest("GET", schemasUrls, nil) - req.SetBasicAuth(opts.AccessKey, opts.SecretKey) + req, err = newRancherRequest(opts.Url, "GET", schemasUrls, nil) if err != nil { return err } + req.SetBasicAuth(opts.AccessKey, opts.SecretKey) resp, err = client.Do(req) if err != nil { @@ -230,7 +230,7 @@ func (rancherClient *RancherBaseClientImpl) newHttpClient() *http.Client { func (rancherClient *RancherBaseClientImpl) doDelete(url string) error { client := rancherClient.newHttpClient() - req, err := http.NewRequest("DELETE", url, nil) + req, err := newRancherRequest(rancherClient.Opts.Url, "DELETE", url, nil) if err != nil { return err } @@ -263,7 +263,14 @@ func (rancherClient *RancherBaseClientImpl) Websocket(url string, headers map[st httpHeaders.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(s))) } - return dialer.Dial(url, http.Header(httpHeaders)) + target, err := validateRancherRequestURL(rancherClient.Opts.Url, url, true) + if err != nil { + return nil, nil, err + } + if !rancherWebSocketURLPattern.MatchString(target) { + return nil, nil, fmt.Errorf("Rancher WebSocket URL failed validation") + } + return dialer.Dial(target, http.Header(httpHeaders)) } func (rancherClient *RancherBaseClientImpl) doGet(url string, opts *ListOpts, respObject interface{}) error { @@ -276,11 +283,11 @@ func (rancherClient *RancherBaseClientImpl) doGet(url string, opts *ListOpts, re } if debug { - fmt.Println("GET " + url) + fmt.Println("GET " + safeRancherURLForLog(url)) } client := rancherClient.newHttpClient() - req, err := http.NewRequest("GET", url, nil) + req, err := newRancherRequest(rancherClient.Opts.Url, "GET", url, nil) if err != nil { return err } @@ -304,7 +311,7 @@ func (rancherClient *RancherBaseClientImpl) doGet(url string, opts *ListOpts, re } if debug { - fmt.Println("Response <= " + string(byteContent)) + fmt.Printf("Response <= %d bytes\n", len(byteContent)) } if err := json.Unmarshal(byteContent, respObject); err != nil { @@ -360,12 +367,11 @@ func (rancherClient *RancherBaseClientImpl) doModify(method string, url string, } if debug { - fmt.Println(method + " " + url) - fmt.Println("Request => " + string(bodyContent)) + fmt.Printf("%s %s request=%d bytes\n", method, safeRancherURLForLog(url), len(bodyContent)) } client := rancherClient.newHttpClient() - req, err := http.NewRequest(method, url, bytes.NewBuffer(bodyContent)) + req, err := newRancherRequest(rancherClient.Opts.Url, method, url, bytes.NewBuffer(bodyContent)) if err != nil { return err } @@ -391,7 +397,7 @@ func (rancherClient *RancherBaseClientImpl) doModify(method string, url string, if len(byteContent) > 0 { if debug { - fmt.Println("Response <= " + string(byteContent)) + fmt.Printf("Response <= %d bytes\n", len(byteContent)) } return json.Unmarshal(byteContent, respObject) } @@ -559,7 +565,7 @@ func (rancherClient *RancherBaseClientImpl) doAction(schemaType string, action s } client := rancherClient.newHttpClient() - req, err := http.NewRequest("POST", actionUrl, input) + req, err := newRancherRequest(rancherClient.Opts.Url, "POST", actionUrl, input) if err != nil { return err } @@ -585,7 +591,7 @@ func (rancherClient *RancherBaseClientImpl) doAction(schemaType string, action s } if debug { - fmt.Println("Response <= " + string(byteContent)) + fmt.Printf("Response <= %d bytes\n", len(byteContent)) } return json.Unmarshal(byteContent, respObject) diff --git a/third_party/go-rancher/v2/security_url.go b/third_party/go-rancher/v2/security_url.go new file mode 100644 index 0000000..e2e8bfd --- /dev/null +++ b/third_party/go-rancher/v2/security_url.go @@ -0,0 +1,85 @@ +package client + +import ( + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" +) + +var ( + rancherHTTPURLPattern = regexp.MustCompile(`^https?://(?:\[[0-9A-Fa-f:.%]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)(?::[0-9]{1,5})?(?:/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?(?:\?[A-Za-z0-9._~!$&'()*+,;=:@%/?-]*)?$`) + rancherWebSocketURLPattern = regexp.MustCompile(`^wss?://(?:\[[0-9A-Fa-f:.%]+\]|[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)(?::[0-9]{1,5})?(?:/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?(?:\?[A-Za-z0-9._~!$&'()*+,;=:@%/?-]*)?$`) +) + +func rancherOrigin(raw string) (string, string, string, error) { + u, err := url.Parse(raw) + if err != nil || u.Opaque != "" || u.User != nil || u.Hostname() == "" { + return "", "", "", fmt.Errorf("Rancher URL must contain a valid origin") + } + scheme := strings.ToLower(u.Scheme) + switch scheme { + case "ws": + scheme = "http" + case "wss": + scheme = "https" + case "http", "https": + default: + return "", "", "", fmt.Errorf("Rancher URL has an unsupported scheme") + } + port := u.Port() + if port == "" { + if scheme == "https" { + port = "443" + } else { + port = "80" + } + } + return scheme, strings.ToLower(u.Hostname()), port, nil +} + +func validateRancherRequestURL(baseURL, candidateURL string, websocketRequest bool) (string, error) { + pattern := rancherHTTPURLPattern + if websocketRequest { + pattern = rancherWebSocketURLPattern + } + if !pattern.MatchString(candidateURL) { + return "", fmt.Errorf("Rancher request URL has an unsupported format") + } + baseScheme, baseHost, basePort, err := rancherOrigin(baseURL) + if err != nil { + return "", err + } + targetScheme, targetHost, targetPort, err := rancherOrigin(candidateURL) + if err != nil { + return "", err + } + if baseScheme != targetScheme || baseHost != targetHost || basePort != targetPort { + return "", fmt.Errorf("Rancher request URL crosses the configured origin") + } + return candidateURL, nil +} + +func newRancherRequest(baseURL, method, candidateURL string, body io.Reader) (*http.Request, error) { + target, err := validateRancherRequestURL(baseURL, candidateURL, false) + if err != nil { + return nil, err + } + if !rancherHTTPURLPattern.MatchString(target) { + return nil, fmt.Errorf("Rancher request URL failed validation") + } + return http.NewRequest(method, target, body) +} + +func safeRancherURLForLog(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "[invalid URL]" + } + u.User = nil + u.RawQuery = "" + u.Fragment = "" + return u.String() +} diff --git a/third_party/go-rancher/v2/security_url_test.go b/third_party/go-rancher/v2/security_url_test.go new file mode 100644 index 0000000..c3aceb7 --- /dev/null +++ b/third_party/go-rancher/v2/security_url_test.go @@ -0,0 +1,32 @@ +package client + +import ( + "strings" + "testing" +) + +func TestValidateRancherRequestURLBindsConfiguredOrigin(t *testing.T) { + base := "https://api.example.test:8443/v2-beta" + accepted, err := validateRancherRequestURL(base, "https://api.example.test:8443/v2-beta/projects?limit=10", false) + if err != nil || accepted == "" { + t.Fatalf("same-origin URL rejected: %q, %v", accepted, err) + } + for _, candidate := range []string{ + "https://metadata.example.test/latest", + "https://user:secret@api.example.test:8443/v2-beta", + "file:///etc/passwd", + } { + if _, err := validateRancherRequestURL(base, candidate, false); err == nil { + t.Fatalf("unsafe URL accepted: %s", candidate) + } + } +} + +func TestSafeRancherURLForLogRemovesSecrets(t *testing.T) { + value := safeRancherURLForLog("https://user:password@example.test/path?token=secret#fragment") + for _, secret := range []string{"user", "password", "token", "secret", "fragment"} { + if strings.Contains(value, secret) { + t.Fatalf("safe URL still contains %q: %s", secret, value) + } + } +}