Something went wrong. Try again.
Cloudflare Tunnel client with NetBSD support
Something went wrong. Try again.
15 kB · 446 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447package quicktunnelauth
import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/require")
func TestConsumeQuickTunnelAuthCallback(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/dashboard?tab=logs") request := newTestQuickTunnelAuthCallbackRequest(login.Cookie, login.State, "broker.assertion")
callback, err := manager.ConsumeCallback(request) require.NoError(t, err) require.NotNil(t, callback) assert.Equal(t, login.State, callback.State) assert.Equal(t, "broker.assertion", callback.Assertion) assert.Equal(t, "/dashboard?tab=logs", callback.ReturnPath)
require.NotNil(t, callback.ClearCookie) assert.Equal(t, quickTunnelAuthStateCookieName(login.State), callback.ClearCookie.Name) assert.Empty(t, callback.ClearCookie.Value) assert.Equal(t, QuickTunnelAuthCallbackPath, callback.ClearCookie.Path) assert.Equal(t, -1, callback.ClearCookie.MaxAge) assert.True(t, callback.ClearCookie.Expires.Before(testQuickTunnelAuthNow)) assert.True(t, callback.ClearCookie.Secure) assert.True(t, callback.ClearCookie.HttpOnly) assert.Equal(t, http.SameSiteLaxMode, callback.ClearCookie.SameSite) assert.Empty(t, callback.ClearCookie.Domain)}
func TestConsumeQuickTunnelAuthCallbackSendsCookieOnCrossSiteBrokerPost(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/dashboard")
// Simulate the broker POST-ing the assertion back from login.trycloudflare.com // to the tunnel callback path. Because trycloudflare.com is a public suffix, // login.trycloudflare.com and test-tunnel.trycloudflare.com are treated as // cross-site by browsers. A SameSite=None cookie is required for the cookie // to be included in this request. request := newTestQuickTunnelAuthCallbackRequest(login.Cookie, login.State, "broker.assertion") request.Header.Set("Origin", "https://login.trycloudflare.com") request.Header.Set("Sec-Fetch-Site", "cross-site")
require.NotNil(t, login.Cookie) assert.Equal(t, http.SameSiteNoneMode, login.Cookie.SameSite) assert.True(t, login.Cookie.Secure) assert.True(t, login.Cookie.HttpOnly)
callback, err := manager.ConsumeCallback(request) require.NoError(t, err) require.NotNil(t, callback) assert.Equal(t, login.State, callback.State) assert.Equal(t, "broker.assertion", callback.Assertion) assert.Equal(t, "/dashboard", callback.ReturnPath)}
func TestConsumeQuickTunnelAuthCallbackSelectsMatchingConcurrentLogin(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) manager.random = bytes.NewReader(append( bytes.Repeat([]byte{1}, quickTunnelAuthStateSize), bytes.Repeat([]byte{2}, quickTunnelAuthStateSize)..., )) firstLogin := beginTestQuickTunnelLogin(t, manager, "/dashboard") secondLogin := beginTestQuickTunnelLogin(t, manager, "/favicon.ico") require.NotEqual(t, firstLogin.Cookie.Name, secondLogin.Cookie.Name)
firstRequest := newTestQuickTunnelAuthCallbackRequest(firstLogin.Cookie, firstLogin.State, "first.assertion") firstRequest.AddCookie(secondLogin.Cookie) firstCallback, err := manager.ConsumeCallback(firstRequest) require.NoError(t, err) assert.Equal(t, "/dashboard", firstCallback.ReturnPath) assert.Equal(t, firstLogin.Cookie.Name, firstCallback.ClearCookie.Name) assert.NotEqual(t, secondLogin.Cookie.Name, firstCallback.ClearCookie.Name)
secondRequest := newTestQuickTunnelAuthCallbackRequest(secondLogin.Cookie, secondLogin.State, "second.assertion") secondRequest.AddCookie(firstLogin.Cookie) secondCallback, err := manager.ConsumeCallback(secondRequest) require.NoError(t, err) assert.Equal(t, "/favicon.ico", secondCallback.ReturnPath) assert.Equal(t, secondLogin.Cookie.Name, secondCallback.ClearCookie.Name)}
func TestConsumeQuickTunnelAuthCallbackAcceptsExplicitHTTPSPort(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") request := newTestQuickTunnelAuthCallbackRequest(login.Cookie, login.State, "broker.assertion") request.Host = "test-tunnel.trycloudflare.com:443"
callback, err := manager.ConsumeCallback(request) require.NoError(t, err) require.NotNil(t, callback)}
// TestConsumeQuickTunnelAuthCallbackAllowsReplayWithinTTL documents the// intentional stateless trade-off. Normal browsers delete the state cookie// after the first response, while a client retaining the callback artifacts can// replay them until the short-lived broker assertion expires.func TestConsumeQuickTunnelAuthCallbackAllowsReplayWithinTTL(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/")
first := newTestQuickTunnelAuthCallbackRequest(login.Cookie, login.State, "broker.assertion") _, err := manager.ConsumeCallback(first) require.NoError(t, err)
second := newTestQuickTunnelAuthCallbackRequest(login.Cookie, login.State, "broker.assertion") callback, err := manager.ConsumeCallback(second) require.NoError(t, err) require.NotNil(t, callback)}
func TestConsumeQuickTunnelAuthCallbackRejectsExpiredState(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") manager.now = func() time.Time { return testQuickTunnelAuthNow.Add(QuickTunnelAuthStateTTL) } request := newTestQuickTunnelAuthCallbackRequest(login.Cookie, login.State, "broker.assertion")
callback, err := manager.ConsumeCallback(request) assert.Nil(t, callback) require.ErrorIs(t, err, errQuickTunnelAuthStateExpired)}
func TestConsumeQuickTunnelAuthCallbackRejectsBrowserMismatch(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") request := newTestQuickTunnelAuthCallbackRequest(nil, login.State, "broker.assertion")
callback, err := manager.ConsumeCallback(request) assert.Nil(t, callback) require.ErrorContains(t, err, "verify authentication-state cookie") require.ErrorContains(t, err, "request contains 0 authentication-state cookies, expected one")}
func TestConsumeQuickTunnelAuthCallbackRejectsTamperedCookie(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") tamperedCookie := &http.Cookie{ Name: login.Cookie.Name, Value: login.Cookie.Value + "A", Path: login.Cookie.Path, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, } request := newTestQuickTunnelAuthCallbackRequest(tamperedCookie, login.State, "broker.assertion")
callback, err := manager.ConsumeCallback(request) assert.Nil(t, callback) require.ErrorContains(t, err, "verify authentication-state cookie")}
func TestVerifyQuickTunnelAuthStateCookieRejectsInvalidCookie(t *testing.T) { t.Parallel()
tests := []struct { name string buildCookie func(t *testing.T, manager *QuickTunnelAuthStateManager, login *QuickTunnelAuthLogin) *http.Cookie duplicate bool }{ { name: "duplicate cookie", buildCookie: func(t *testing.T, _ *QuickTunnelAuthStateManager, login *QuickTunnelAuthLogin) *http.Cookie { t.Helper() return login.Cookie }, duplicate: true, }, { name: "non-canonical state", buildCookie: func(t *testing.T, manager *QuickTunnelAuthStateManager, login *QuickTunnelAuthLogin) *http.Cookie { t.Helper() return newTestQuickTunnelAuthStateCookie(t, manager, quickTunnelAuthStateCookiePayload{ State: login.State + "=", ExpiresAt: login.Cookie.Expires.Unix(), }) }, }, { name: "non-positive expiry", buildCookie: func(t *testing.T, manager *QuickTunnelAuthStateManager, login *QuickTunnelAuthLogin) *http.Cookie { t.Helper() return newTestQuickTunnelAuthStateCookie(t, manager, quickTunnelAuthStateCookiePayload{ State: login.State, ExpiresAt: 0, }) }, }, }
for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") cookie := test.buildCookie(t, manager, login) request := newTestQuickTunnelAuthCallbackRequest(cookie, login.State, "broker.assertion") if test.duplicate { request.AddCookie(cookie) }
callback, err := manager.ConsumeCallback(request) assert.Nil(t, callback) require.ErrorContains(t, err, "verify authentication-state cookie") if test.duplicate { require.ErrorContains(t, err, "request contains 2 authentication-state cookies, expected one") } }) }}
func TestConsumeQuickTunnelAuthCallbackRejectsMismatchedState(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") otherState := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{3}, quickTunnelAuthStateSize)) request := newTestQuickTunnelAuthCallbackRequest(login.Cookie, otherState, "broker.assertion")
callback, err := manager.ConsumeCallback(request) assert.Nil(t, callback) require.ErrorContains(t, err, "request contains 0 authentication-state cookies, expected one")}
func TestConsumeQuickTunnelAuthCallbackRejectsInvalidRequest(t *testing.T) { t.Parallel()
tests := []struct { name string mutate func(*http.Request) }{ { name: "wrong method", mutate: func(r *http.Request) { r.Method = http.MethodGet }, }, { name: "wrong hostname", mutate: func(r *http.Request) { r.Host = "other.trycloudflare.com" }, }, { name: "unexpected port", mutate: func(r *http.Request) { r.Host = "test-tunnel.trycloudflare.com:8443" }, }, { name: "wrong path", mutate: func(r *http.Request) { r.URL.Path = "/callback" }, }, { name: "callback query", mutate: func(r *http.Request) { r.URL.RawQuery = "state=value" }, }, { name: "callback fragment", mutate: func(r *http.Request) { r.URL.Fragment = "section" }, }, { name: "wrong content type", mutate: func(r *http.Request) { r.Header.Set("Content-Type", "application/json") }, }, }
for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") request := newTestQuickTunnelAuthCallbackRequest(login.Cookie, login.State, "broker.assertion") test.mutate(request)
callback, err := manager.ConsumeCallback(request) assert.Nil(t, callback) require.ErrorContains(t, err, "validate callback request") }) }}
func TestConsumeQuickTunnelAuthCallbackRejectsInvalidForm(t *testing.T) { t.Parallel()
tests := []struct { name string body string }{ {name: "empty", body: ""}, {name: "missing state", body: "assertion=broker.assertion"}, {name: "missing assertion", body: "state=value"}, {name: "empty state", body: "state=&assertion=broker.assertion"}, {name: "empty assertion", body: "state=value&assertion="}, {name: "duplicate state", body: "state=one&state=two&assertion=broker.assertion"}, {name: "extra field", body: "state=value&assertion=broker.assertion&extra=value"}, {name: "malformed encoding", body: "state=%zz&assertion=broker.assertion"}, }
for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") request := newTestQuickTunnelAuthCallbackRequestWithBody(login.Cookie, test.body)
callback, err := manager.ConsumeCallback(request) assert.Nil(t, callback) require.ErrorContains(t, err, "parse callback form") }) }}
func TestConsumeQuickTunnelAuthCallbackRejectsOversizedForm(t *testing.T) { t.Parallel()
tests := []struct { name string contentLength int64 }{ {name: "known content length", contentLength: quickTunnelAuthCallbackMaxBodyBytes + 1}, {name: "unknown content length", contentLength: -1}, }
for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel()
manager := newTestQuickTunnelAuthStateManager(t) login := beginTestQuickTunnelLogin(t, manager, "/") request := newTestQuickTunnelAuthCallbackRequestWithBody( login.Cookie, strings.Repeat("a", quickTunnelAuthCallbackMaxBodyBytes+1), ) request.ContentLength = test.contentLength
callback, err := manager.ConsumeCallback(request) assert.Nil(t, callback) require.ErrorContains(t, err, "parse callback form") }) }}
func beginTestQuickTunnelLogin( t *testing.T, manager *QuickTunnelAuthStateManager, path string,) *QuickTunnelAuthLogin { t.Helper()
request := httptest.NewRequest(http.MethodGet, "https://test-tunnel.trycloudflare.com"+path, nil) login, err := manager.BeginLogin(request) require.NoError(t, err) require.NotNil(t, login) return login}
func newTestQuickTunnelAuthCallbackRequest( cookie *http.Cookie, state string, assertion string,) *http.Request { return newTestQuickTunnelAuthCallbackRequestWithBody(cookie, url.Values{ "assertion": []string{assertion}, "state": []string{state}, }.Encode())}
func newTestQuickTunnelAuthCallbackRequestWithBody(cookie *http.Cookie, body string) *http.Request { request := httptest.NewRequest( http.MethodPost, "https://test-tunnel.trycloudflare.com"+QuickTunnelAuthCallbackPath, strings.NewReader(body), ) request.Header.Set("Content-Type", quickTunnelAuthCallbackContentType+"; charset=UTF-8") if cookie != nil { request.AddCookie(cookie) } return request}
func newTestQuickTunnelAuthStateCookie( t *testing.T, manager *QuickTunnelAuthStateManager, payload quickTunnelAuthStateCookiePayload,) *http.Cookie { t.Helper()
payloadBytes, err := json.Marshal(payload) require.NoError(t, err) encodedPayload := base64.RawURLEncoding.EncodeToString(payloadBytes) mac := hmac.New(sha256.New, manager.signingKey) _, err = mac.Write([]byte(encodedPayload)) require.NoError(t, err)
// #nosec G124 -- valid signed test cookies must model the cross-site callback cookie. return &http.Cookie{ Name: quickTunnelAuthStateCookieName(payload.State), Value: encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), Path: QuickTunnelAuthCallbackPath, Secure: true, HttpOnly: true, SameSite: http.SameSiteNoneMode, }}