Skip to content

Commit 4fd6d64

Browse files
authored
security: bind Rancher requests to configured origin (#1)
1 parent e28011a commit 4fd6d64

4 files changed

Lines changed: 136 additions & 14 deletions

File tree

‎internal/events/listener.go‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"net/url"
1111
"os"
12+
"regexp"
1213
"strconv"
1314
"strings"
1415
"time"
@@ -20,6 +21,8 @@ import (
2021

2122
const MaxWait = time.Duration(time.Second * 10)
2223

24+
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._~!$&'()*+,;=:@%/?-]*)?$`)
25+
2326
// EventHandler Defines the function "interface" that handlers must conform to.
2427
type EventHandler func(*Event, *client.RancherClient) error
2528

@@ -71,7 +74,10 @@ func NewEventRouter(name string, priority int, apiURL string, accessKey string,
7174
subscribe.Path = strings.TrimSuffix(subscribe.Path, "/") + "/subscribe"
7275
subscribe.RawQuery = ""
7376
subscribe.Fragment = ""
74-
subscribeURL := subscribe.String()
77+
subscribeURL, err := validateEventSubscriptionURL(apiURL, subscribe.String())
78+
if err != nil {
79+
return nil, err
80+
}
7581

7682
return &EventRouter{
7783
name: name,
@@ -196,7 +202,14 @@ func (router *EventRouter) subscribeToEvents(subscribeURL string, accessKey stri
196202
}
197203
}
198204
endpoint.RawQuery = query.Encode()
199-
ws, resp, err := dialer.Dial(endpoint.String(), headers)
205+
target, err := validateEventSubscriptionURL(router.apiURL, endpoint.String())
206+
if err != nil {
207+
return nil, err
208+
}
209+
if !eventSubscriptionURLPattern.MatchString(target) {
210+
return nil, fmt.Errorf("event subscription URL failed validation")
211+
}
212+
ws, resp, err := dialer.Dial(target, headers)
200213

201214
if err != nil {
202215
log.WithFields(log.Fields{
@@ -220,6 +233,30 @@ func (router *EventRouter) subscribeToEvents(subscribeURL string, accessKey stri
220233
return ws, nil
221234
}
222235

236+
func validateEventSubscriptionURL(apiURL, candidateURL string) (string, error) {
237+
if !eventSubscriptionURLPattern.MatchString(candidateURL) {
238+
return "", fmt.Errorf("event subscription URL has an unsupported format")
239+
}
240+
base, err := url.Parse(apiURL)
241+
if err != nil || base.Opaque != "" || base.User != nil || base.Hostname() == "" {
242+
return "", fmt.Errorf("event API URL must contain a valid origin")
243+
}
244+
target, err := url.Parse(candidateURL)
245+
if err != nil || target.Opaque != "" || target.User != nil || target.Hostname() == "" {
246+
return "", fmt.Errorf("event subscription URL must contain a valid origin")
247+
}
248+
expectedScheme := "ws"
249+
if strings.EqualFold(base.Scheme, "https") {
250+
expectedScheme = "wss"
251+
} else if !strings.EqualFold(base.Scheme, "http") {
252+
return "", fmt.Errorf("unsupported event API URL scheme %q", base.Scheme)
253+
}
254+
if !strings.EqualFold(target.Scheme, expectedScheme) || !strings.EqualFold(target.Host, base.Host) {
255+
return "", fmt.Errorf("event subscription URL crosses the configured origin")
256+
}
257+
return target.String(), nil
258+
}
259+
223260
func (router *EventRouter) GetWebSocketConn() *websocket.Conn {
224261
return router.eventStream
225262
}

‎internal/events/listener_test.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,13 @@ func TestNewEventRouterRejectsUnsupportedScheme(t *testing.T) {
2727
t.Fatal("expected unsupported event API URL scheme to fail")
2828
}
2929
}
30+
31+
func TestEventSubscriptionRejectsCrossOriginAddress(t *testing.T) {
32+
_, err := validateEventSubscriptionURL(
33+
"https://api.example.test/v2-beta",
34+
"wss://metadata.example.test/v2-beta/subscribe",
35+
)
36+
if err == nil {
37+
t.Fatal("expected cross-origin event subscription URL to fail")
38+
}
39+
}

‎internal/rancherclient/v2/common.go‎

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ const (
2424
)
2525

2626
var (
27-
debug = false
28-
dialer = &websocket.Dialer{}
29-
privateFieldRegex = regexp.MustCompile("^[[:lower:]]")
27+
debug = false
28+
dialer = &websocket.Dialer{}
29+
privateFieldRegex = regexp.MustCompile("^[[:lower:]]")
30+
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._~!$&'()*+,;=:@%/?-]*)?$`)
31+
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._~!$&'()*+,;=:@%/?-]*)?$`)
3032
)
3133

3234
type ClientOpts struct {
@@ -146,6 +148,65 @@ func NormalizeUrl(existingUrl string) (string, error) {
146148
return u.String(), nil
147149
}
148150

151+
func rancherOrigin(raw string) (string, string, string, error) {
152+
u, err := url.Parse(raw)
153+
if err != nil || u.Opaque != "" || u.User != nil || u.Hostname() == "" {
154+
return "", "", "", errors.New("Rancher URL must contain a valid origin")
155+
}
156+
scheme := strings.ToLower(u.Scheme)
157+
switch scheme {
158+
case "ws":
159+
scheme = "http"
160+
case "wss":
161+
scheme = "https"
162+
case "http", "https":
163+
default:
164+
return "", "", "", errors.New("Rancher URL has an unsupported scheme")
165+
}
166+
port := u.Port()
167+
if port == "" {
168+
if scheme == "https" {
169+
port = "443"
170+
} else {
171+
port = "80"
172+
}
173+
}
174+
return scheme, strings.ToLower(u.Hostname()), port, nil
175+
}
176+
177+
func validateRancherRequestURL(baseURL, candidateURL string, websocketRequest bool) (string, error) {
178+
pattern := rancherHTTPURLPattern
179+
if websocketRequest {
180+
pattern = rancherWebSocketURLPattern
181+
}
182+
if !pattern.MatchString(candidateURL) {
183+
return "", errors.New("Rancher request URL has an unsupported format")
184+
}
185+
baseScheme, baseHost, basePort, err := rancherOrigin(baseURL)
186+
if err != nil {
187+
return "", err
188+
}
189+
targetScheme, targetHost, targetPort, err := rancherOrigin(candidateURL)
190+
if err != nil {
191+
return "", err
192+
}
193+
if baseScheme != targetScheme || baseHost != targetHost || basePort != targetPort {
194+
return "", errors.New("Rancher request URL crosses the configured origin")
195+
}
196+
return candidateURL, nil
197+
}
198+
199+
func newRancherRequest(baseURL, method, candidateURL string, body io.Reader) (*http.Request, error) {
200+
target, err := validateRancherRequestURL(baseURL, candidateURL, false)
201+
if err != nil {
202+
return nil, err
203+
}
204+
if !rancherHTTPURLPattern.MatchString(target) {
205+
return nil, errors.New("Rancher request URL failed validation")
206+
}
207+
return http.NewRequest(method, target, body)
208+
}
209+
149210
func safeURLForLog(value string) string {
150211
u, err := url.Parse(value)
151212
if err != nil {
@@ -182,7 +243,7 @@ func setupRancherBaseClient(rancherClient *RancherBaseClientImpl, opts *ClientOp
182243
opts.Timeout = time.Second * time.Duration(defaultTimeout())
183244
}
184245
client := newHTTPClient(opts.Timeout)
185-
req, err := http.NewRequest("GET", opts.Url, nil)
246+
req, err := newRancherRequest(opts.Url, "GET", opts.Url, nil)
186247
if err != nil {
187248
return err
188249
}
@@ -206,7 +267,7 @@ func setupRancherBaseClient(rancherClient *RancherBaseClientImpl, opts *ClientOp
206267
}
207268

208269
if schemasUrls != opts.Url {
209-
req, err = http.NewRequest("GET", schemasUrls, nil)
270+
req, err = newRancherRequest(opts.Url, "GET", schemasUrls, nil)
210271
if err != nil {
211272
return err
212273
}
@@ -264,7 +325,7 @@ func (rancherClient *RancherBaseClientImpl) newHttpClient() *http.Client {
264325

265326
func (rancherClient *RancherBaseClientImpl) doDelete(url string) error {
266327
client := rancherClient.newHttpClient()
267-
req, err := http.NewRequest("DELETE", url, nil)
328+
req, err := newRancherRequest(rancherClient.Opts.Url, "DELETE", url, nil)
268329
if err != nil {
269330
return err
270331
}
@@ -297,7 +358,14 @@ func (rancherClient *RancherBaseClientImpl) Websocket(url string, headers map[st
297358
httpHeaders.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(s)))
298359
}
299360

300-
return dialer.Dial(url, http.Header(httpHeaders))
361+
target, err := validateRancherRequestURL(rancherClient.Opts.Url, url, true)
362+
if err != nil {
363+
return nil, nil, err
364+
}
365+
if !rancherWebSocketURLPattern.MatchString(target) {
366+
return nil, nil, errors.New("Rancher WebSocket URL failed validation")
367+
}
368+
return dialer.Dial(target, http.Header(httpHeaders))
301369
}
302370

303371
func (rancherClient *RancherBaseClientImpl) doGet(url string, opts *ListOpts, respObject interface{}) error {
@@ -314,7 +382,7 @@ func (rancherClient *RancherBaseClientImpl) doGet(url string, opts *ListOpts, re
314382
}
315383

316384
client := rancherClient.newHttpClient()
317-
req, err := http.NewRequest("GET", url, nil)
385+
req, err := newRancherRequest(rancherClient.Opts.Url, "GET", url, nil)
318386
if err != nil {
319387
return err
320388
}
@@ -398,7 +466,7 @@ func (rancherClient *RancherBaseClientImpl) doModify(method string, url string,
398466
}
399467

400468
client := rancherClient.newHttpClient()
401-
req, err := http.NewRequest(method, url, bytes.NewBuffer(bodyContent))
469+
req, err := newRancherRequest(rancherClient.Opts.Url, method, url, bytes.NewBuffer(bodyContent))
402470
if err != nil {
403471
return err
404472
}
@@ -592,7 +660,7 @@ func (rancherClient *RancherBaseClientImpl) doAction(schemaType string, action s
592660
}
593661

594662
client := rancherClient.newHttpClient()
595-
req, err := http.NewRequest("POST", actionUrl, input)
663+
req, err := newRancherRequest(rancherClient.Opts.Url, "POST", actionUrl, input)
596664
if err != nil {
597665
return err
598666
}

‎internal/rancherclient/v2/common_test.go‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func TestWebsocketForwardsHeadersAndBasicAuth(t *testing.T) {
8080
}))
8181
defer server.Close()
8282

83-
client := &RancherBaseClientImpl{Opts: &ClientOpts{AccessKey: "access", SecretKey: "secret"}}
83+
client := &RancherBaseClientImpl{Opts: &ClientOpts{Url: server.URL, AccessKey: "access", SecretKey: "secret"}}
8484
connection, _, err := client.Websocket("ws"+strings.TrimPrefix(server.URL, "http"), map[string][]string{"X-Trace-ID": {"trace-1"}})
8585
if err != nil {
8686
t.Fatal(err)
@@ -106,7 +106,7 @@ func TestHTTPClientBlocksCrossOriginRedirect(t *testing.T) {
106106
}))
107107
defer source.Close()
108108

109-
client := &RancherBaseClientImpl{Opts: &ClientOpts{AccessKey: "access", SecretKey: "secret", Timeout: time.Second}}
109+
client := &RancherBaseClientImpl{Opts: &ClientOpts{Url: source.URL, AccessKey: "access", SecretKey: "secret", Timeout: time.Second}}
110110
err := client.doGet(source.URL, nil, &map[string]interface{}{})
111111
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect blocked") {
112112
t.Fatalf("unexpected redirect result: %v", err)
@@ -116,6 +116,13 @@ func TestHTTPClientBlocksCrossOriginRedirect(t *testing.T) {
116116
}
117117
}
118118

119+
func TestRancherRequestRejectsCrossOriginAPIAddress(t *testing.T) {
120+
_, err := newRancherRequest("https://api.example.test/v2-beta", http.MethodGet, "https://metadata.example.test/latest", nil)
121+
if err == nil || !strings.Contains(err.Error(), "crosses the configured origin") {
122+
t.Fatalf("unexpected cross-origin result: %v", err)
123+
}
124+
}
125+
119126
func TestSafeURLForLogRemovesSecrets(t *testing.T) {
120127
value := safeURLForLog("https://user:password@example.test/path?token=secret#fragment")
121128
for _, secret := range []string{"password", "token", "secret", "fragment"} {

0 commit comments

Comments
 (0)