mirror of
https://github.com/cloudflare/cloudflared.git
synced 2026-10-12 02:12:14 +00:00
TUN-10800: Wire Quick Tunnel authentication redirects
Wires protected Quick Tunnel authentication into the request path so unauthenticated requests are intercepted before ingress selection. Broker callbacks are handled locally and fail closed
This commit is contained in:
committed by
João "Pisco" Fernandes
parent
a1dd7d16ba
commit
5539e4590c
@@ -448,7 +448,14 @@ func StartServer(
|
||||
logger.ManagementLogger,
|
||||
)
|
||||
internalRules := []ingress.Rule{ingress.NewManagementRule(mgmt)}
|
||||
orchestrator, err := orchestration.NewOrchestrator(ctx, orchestratorConfig, tunnelConfig.Tags, internalRules, tunnelConfig.Log)
|
||||
orchestrator, err := orchestration.NewOrchestratorWithHTTPRequestInterceptor(
|
||||
ctx,
|
||||
orchestratorConfig,
|
||||
tunnelConfig.Tags,
|
||||
internalRules,
|
||||
namedTunnel.QuickTunnelAuth,
|
||||
tunnelConfig.Log,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
|
||||
"github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
|
||||
"github.com/cloudflare/cloudflared/connection"
|
||||
"github.com/cloudflare/cloudflared/quicktunnelauth"
|
||||
)
|
||||
|
||||
const httpTimeout = 15 * time.Second
|
||||
@@ -116,14 +117,26 @@ func RunQuickTunnel(sc *subcommandContext) error {
|
||||
TunnelID: tunnelID,
|
||||
}
|
||||
|
||||
url := data.Result.Hostname
|
||||
if !strings.HasPrefix(url, "https://") {
|
||||
url = "https://" + url
|
||||
var quickTunnelAuth connection.HTTPRequestInterceptor
|
||||
if isProtected {
|
||||
stateManager, err := quicktunnelauth.NewQuickTunnelAuthStateManager(data.Result.Hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize Quick Tunnel authentication state: %w", err)
|
||||
}
|
||||
quickTunnelAuth, err = quicktunnelauth.NewQuickTunnelAuthHandler(stateManager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize Quick Tunnel authentication handler: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
quickTunnelURL := data.Result.Hostname
|
||||
if !strings.HasPrefix(quickTunnelURL, "https://") {
|
||||
quickTunnelURL = "https://" + quickTunnelURL
|
||||
}
|
||||
|
||||
cliutil.LogTable(sc.log, []string{
|
||||
"Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):",
|
||||
url,
|
||||
quickTunnelURL,
|
||||
})
|
||||
|
||||
if !sc.c.IsSet(flags.Protocol) {
|
||||
@@ -136,7 +149,11 @@ func RunQuickTunnel(sc *subcommandContext) error {
|
||||
return StartServer(
|
||||
sc.c,
|
||||
buildInfo,
|
||||
&connection.TunnelProperties{Credentials: credentials, QuickTunnelUrl: data.Result.Hostname, IsProtected: isProtected},
|
||||
&connection.TunnelProperties{
|
||||
Credentials: credentials,
|
||||
QuickTunnelUrl: data.Result.Hostname,
|
||||
QuickTunnelAuth: quickTunnelAuth,
|
||||
},
|
||||
sc.log,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ type Orchestrator interface {
|
||||
}
|
||||
|
||||
type TunnelProperties struct {
|
||||
Credentials Credentials
|
||||
QuickTunnelUrl string
|
||||
IsProtected bool
|
||||
Credentials Credentials
|
||||
QuickTunnelUrl string
|
||||
QuickTunnelAuth HTTPRequestInterceptor
|
||||
}
|
||||
|
||||
// Credentials are stored in the credentials file and contain all info needed to run a tunnel.
|
||||
@@ -148,6 +148,12 @@ func (t Type) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPRequestInterceptor handles an HTTP request before ingress selection.
|
||||
// When configured, it owns the response and the request does not reach the origin.
|
||||
type HTTPRequestInterceptor interface {
|
||||
HandleHTTP(w http.ResponseWriter, r *http.Request) error
|
||||
}
|
||||
|
||||
// OriginProxy is how data flows from cloudflared to the origin services running behind it.
|
||||
type OriginProxy interface {
|
||||
ProxyHTTP(w ResponseWriter, tr *tracing.TracedHTTPRequest, isWebsocket bool) error
|
||||
|
||||
@@ -46,7 +46,7 @@ func (rc *newLocalConfig) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
|
||||
func convertToUnvalidatedIngressRules(i ingress.Ingress) []config.UnvalidatedIngressRule {
|
||||
result := make([]config.UnvalidatedIngressRule, 0)
|
||||
result := make([]config.UnvalidatedIngressRule, 0, len(i.Rules))
|
||||
for _, rule := range i.Rules {
|
||||
var path string
|
||||
if rule.Path != nil {
|
||||
|
||||
@@ -40,7 +40,9 @@ type Orchestrator struct {
|
||||
flowLimiter cfdflow.Limiter
|
||||
// Origin dialer service to manage egress socket dialing.
|
||||
originDialerService *ingress.OriginDialerService
|
||||
log *zerolog.Logger
|
||||
// httpRequestInterceptor is process-local and persists across configuration updates.
|
||||
httpRequestInterceptor connection.HTTPRequestInterceptor
|
||||
log *zerolog.Logger
|
||||
|
||||
// orchestrator must not handle any more updates after shutdownC is closed
|
||||
shutdownC <-chan struct{}
|
||||
@@ -53,19 +55,43 @@ func NewOrchestrator(ctx context.Context,
|
||||
tags []pogs.Tag,
|
||||
internalRules []ingress.Rule,
|
||||
log *zerolog.Logger,
|
||||
) (*Orchestrator, error) {
|
||||
return newOrchestrator(ctx, config, tags, internalRules, nil, log)
|
||||
}
|
||||
|
||||
// NewOrchestratorWithHTTPRequestInterceptor creates an Orchestrator with a process-local HTTP request interceptor.
|
||||
func NewOrchestratorWithHTTPRequestInterceptor(
|
||||
ctx context.Context,
|
||||
config *Config,
|
||||
tags []pogs.Tag,
|
||||
internalRules []ingress.Rule,
|
||||
httpRequestInterceptor connection.HTTPRequestInterceptor,
|
||||
log *zerolog.Logger,
|
||||
) (*Orchestrator, error) {
|
||||
return newOrchestrator(ctx, config, tags, internalRules, httpRequestInterceptor, log)
|
||||
}
|
||||
|
||||
func newOrchestrator(
|
||||
ctx context.Context,
|
||||
config *Config,
|
||||
tags []pogs.Tag,
|
||||
internalRules []ingress.Rule,
|
||||
httpRequestInterceptor connection.HTTPRequestInterceptor,
|
||||
log *zerolog.Logger,
|
||||
) (*Orchestrator, error) {
|
||||
o := &Orchestrator{
|
||||
// Lowest possible version, any remote configuration will have version higher than this
|
||||
// Starting at -1 allows a configuration migration (local to remote) to override the current configuration as it
|
||||
// will start at version 0.
|
||||
currentVersion: -1,
|
||||
internalRules: internalRules,
|
||||
config: config,
|
||||
tags: tags,
|
||||
flowLimiter: cfdflow.NewLimiter(config.WarpRouting.MaxActiveFlows),
|
||||
originDialerService: config.OriginDialerService,
|
||||
log: log,
|
||||
shutdownC: ctx.Done(),
|
||||
currentVersion: -1,
|
||||
internalRules: internalRules,
|
||||
config: config,
|
||||
tags: tags,
|
||||
flowLimiter: cfdflow.NewLimiter(config.WarpRouting.MaxActiveFlows),
|
||||
originDialerService: config.OriginDialerService,
|
||||
httpRequestInterceptor: httpRequestInterceptor,
|
||||
log: log,
|
||||
shutdownC: ctx.Done(),
|
||||
}
|
||||
if err := o.updateIngress(*config.Ingress, config.WarpRouting); err != nil {
|
||||
return nil, err
|
||||
@@ -186,8 +212,16 @@ func (o *Orchestrator) updateIngress(ingressRules ingress.Ingress, warpRouting i
|
||||
// runtime in response to a configuration push except when starting a tunnel connection.
|
||||
o.originDialerService.UpdateDefaultDialer(ingress.NewDialer(warpRouting))
|
||||
|
||||
// Create and replace the origin proxy with a new instance
|
||||
proxy := proxy.NewOriginProxy(ingressRules, o.originDialerService, o.tags, o.flowLimiter, o.log)
|
||||
// Create and replace the origin proxy with a new instance. The request
|
||||
// interceptor is process-local and remains installed across remote ingress updates.
|
||||
proxy := proxy.NewOriginProxyWithHTTPRequestInterceptor(
|
||||
ingressRules,
|
||||
o.originDialerService,
|
||||
o.tags,
|
||||
o.flowLimiter,
|
||||
o.httpRequestInterceptor,
|
||||
o.log,
|
||||
)
|
||||
o.proxy.Store(proxy)
|
||||
o.config.Ingress = &ingressRules
|
||||
o.config.WarpRouting = warpRouting
|
||||
|
||||
@@ -48,6 +48,13 @@ var (
|
||||
})
|
||||
)
|
||||
|
||||
type testHTTPRequestInterceptor struct{}
|
||||
|
||||
func (*testHTTPRequestInterceptor) HandleHTTP(w http.ResponseWriter, _ *http.Request) error {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestUpdateConfiguration tests that
|
||||
// - configurations can be deserialized
|
||||
// - proxy can be updated
|
||||
@@ -186,6 +193,38 @@ func TestUpdateConfiguration(t *testing.T) {
|
||||
require.NotEqual(t, originProxyV10, originProxyV2)
|
||||
}
|
||||
|
||||
func TestHTTPRequestInterceptorPersistsAcrossConfigurationUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
originDialer := ingress.NewOriginDialer(ingress.OriginConfig{
|
||||
DefaultDialer: testDefaultDialer,
|
||||
TCPWriteTimeout: time.Second,
|
||||
}, &testLogger)
|
||||
orchestrator, err := NewOrchestratorWithHTTPRequestInterceptor(
|
||||
t.Context(),
|
||||
&Config{
|
||||
Ingress: &ingress.Ingress{},
|
||||
OriginDialerService: originDialer,
|
||||
},
|
||||
testTags,
|
||||
nil,
|
||||
&testHTTPRequestInterceptor{},
|
||||
&testLogger,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
updateWithValidation(t, orchestrator, 1, []byte(`{
|
||||
"ingress": [{"service": "http_status:404"}],
|
||||
"warp-routing": {}
|
||||
}`))
|
||||
originProxy, err := orchestrator.GetOriginProxy()
|
||||
require.NoError(t, err)
|
||||
response, err := proxyHTTP(originProxy, "test-tunnel.trycloudflare.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusTeapot, response.StatusCode)
|
||||
require.NoError(t, response.Body.Close())
|
||||
}
|
||||
|
||||
// Validates that a new version 0 will be applied if the configuration is loaded locally.
|
||||
// This will happen when a locally managed tunnel is migrated to remote configuration and receives its first configuration.
|
||||
func TestUpdateConfiguration_FromMigration(t *testing.T) {
|
||||
@@ -262,7 +301,7 @@ func TestConcurrentUpdateAndRead(t *testing.T) {
|
||||
|
||||
tcpOrigin, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer tcpOrigin.Close()
|
||||
defer func() { _ = tcpOrigin.Close() }()
|
||||
|
||||
originDialer := ingress.NewOriginDialer(ingress.OriginConfig{
|
||||
DefaultDialer: testDefaultDialer,
|
||||
@@ -347,7 +386,7 @@ func TestConcurrentUpdateAndRead(t *testing.T) {
|
||||
defer wg.Done()
|
||||
resp, err := proxyHTTP(originProxy, hostname)
|
||||
assert.NoError(t, err, "proxyHTTP %d failed %v", i, err)
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// The response can be from initOrigin, http_status:204 or http_status:418
|
||||
switch resp.StatusCode {
|
||||
@@ -374,7 +413,7 @@ func TestConcurrentUpdateAndRead(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer pw.Close()
|
||||
defer func() { _ = pw.Close() }()
|
||||
tcpEyeball(t, pw, tcpBody, w)
|
||||
}()
|
||||
|
||||
@@ -528,7 +567,7 @@ func serveTCPOrigin(t *testing.T, tcpOrigin net.Listener, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
echoTCP(t, conn)
|
||||
}()
|
||||
@@ -682,7 +721,7 @@ func TestPersistentConnection(t *testing.T) {
|
||||
|
||||
tcpOrigin, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer tcpOrigin.Close()
|
||||
defer func() { _ = tcpOrigin.Close() }()
|
||||
|
||||
configWithWSAndWarp := []byte(fmt.Sprintf(`
|
||||
{
|
||||
@@ -717,7 +756,7 @@ func TestPersistentConnection(t *testing.T) {
|
||||
defer wg.Done()
|
||||
conn, err := tcpOrigin.Accept()
|
||||
assert.NoError(t, err)
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// Expect 3 TCP messages
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -772,8 +811,8 @@ func TestPersistentConnection(t *testing.T) {
|
||||
validateWsEcho(t, msg, wsReqWriter, wsRespReadWriter)
|
||||
tcpEyeball(t, tcpReqWriter, msg, tcpRespReadWriter)
|
||||
|
||||
wsReqWriter.Close()
|
||||
tcpReqWriter.Close()
|
||||
require.NoError(t, wsReqWriter.Close())
|
||||
require.NoError(t, tcpReqWriter.Close())
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -797,7 +836,7 @@ func wsEcho(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
for {
|
||||
mt, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
|
||||
+41
-13
@@ -34,11 +34,12 @@ const (
|
||||
|
||||
// Proxy represents a means to Proxy between cloudflared and the origin services.
|
||||
type Proxy struct {
|
||||
ingressRules ingress.Ingress
|
||||
originDialer ingress.OriginTCPDialer
|
||||
tags []pogs.Tag
|
||||
flowLimiter cfdflow.Limiter
|
||||
log *zerolog.Logger
|
||||
ingressRules ingress.Ingress
|
||||
originDialer ingress.OriginTCPDialer
|
||||
tags []pogs.Tag
|
||||
flowLimiter cfdflow.Limiter
|
||||
httpRequestInterceptor connection.HTTPRequestInterceptor
|
||||
log *zerolog.Logger
|
||||
}
|
||||
|
||||
// NewOriginProxy returns a new instance of the Proxy struct.
|
||||
@@ -49,15 +50,35 @@ func NewOriginProxy(
|
||||
flowLimiter cfdflow.Limiter,
|
||||
log *zerolog.Logger,
|
||||
) *Proxy {
|
||||
proxy := &Proxy{
|
||||
ingressRules: ingressRules,
|
||||
originDialer: originDialer,
|
||||
tags: tags,
|
||||
flowLimiter: flowLimiter,
|
||||
log: log,
|
||||
}
|
||||
return NewOriginProxyWithHTTPRequestInterceptor(
|
||||
ingressRules,
|
||||
originDialer,
|
||||
tags,
|
||||
flowLimiter,
|
||||
nil,
|
||||
log,
|
||||
)
|
||||
}
|
||||
|
||||
return proxy
|
||||
// NewOriginProxyWithHTTPRequestInterceptor returns a new Proxy that can intercept
|
||||
// HTTP requests before ingress selection. A nil interceptor allows requests to
|
||||
// continue to ingress selection.
|
||||
func NewOriginProxyWithHTTPRequestInterceptor(
|
||||
ingressRules ingress.Ingress,
|
||||
originDialer ingress.OriginDialer,
|
||||
tags []pogs.Tag,
|
||||
flowLimiter cfdflow.Limiter,
|
||||
httpRequestInterceptor connection.HTTPRequestInterceptor,
|
||||
log *zerolog.Logger,
|
||||
) *Proxy {
|
||||
return &Proxy{
|
||||
ingressRules: ingressRules,
|
||||
originDialer: originDialer,
|
||||
tags: tags,
|
||||
flowLimiter: flowLimiter,
|
||||
httpRequestInterceptor: httpRequestInterceptor,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) applyIngressMiddleware(rule *ingress.Rule, r *http.Request, w connection.ResponseWriter) (error, bool) {
|
||||
@@ -86,6 +107,13 @@ func (p *Proxy) ProxyHTTP(
|
||||
defer decrementConcurrentRequests()
|
||||
|
||||
req := tr.Request
|
||||
if p.httpRequestInterceptor != nil {
|
||||
if err := p.httpRequestInterceptor.HandleHTTP(w, req); err != nil {
|
||||
p.log.Warn().Err(err).Msg("HTTP request handler failed before origin selection")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
p.appendTagHeaders(req)
|
||||
_, ruleSpan := tr.Tracer().Start(req.Context(), "ingress_match",
|
||||
trace.WithAttributes(attribute.String("req-host", req.Host)))
|
||||
|
||||
@@ -80,6 +80,16 @@ func (m *mockHTTPRespWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
panic("Hijack not implemented")
|
||||
}
|
||||
|
||||
type mockHTTPRequestInterceptor struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (h *mockHTTPRequestInterceptor) HandleHTTP(w http.ResponseWriter, _ *http.Request) error {
|
||||
h.called = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockWSRespWriter struct {
|
||||
*mockHTTPRespWriter
|
||||
writeNotification chan []byte
|
||||
@@ -145,6 +155,36 @@ func (w *mockSSERespWriter) ReadBytes() []byte {
|
||||
return <-w.writeNotification
|
||||
}
|
||||
|
||||
func TestHTTPRequestInterceptorRunsBeforeIngressSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := zerolog.Nop()
|
||||
interceptor := &mockHTTPRequestInterceptor{}
|
||||
originProxy := NewOriginProxyWithHTTPRequestInterceptor(
|
||||
ingress.Ingress{},
|
||||
nil,
|
||||
testTags,
|
||||
cfdflow.NewLimiter(0),
|
||||
interceptor,
|
||||
&log,
|
||||
)
|
||||
responseWriter := newMockHTTPRespWriter()
|
||||
request, err := http.NewRequest(http.MethodGet, "https://test-tunnel.trycloudflare.com/", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = originProxy.ProxyHTTP(
|
||||
responseWriter,
|
||||
tracing.NewTracedHTTPRequest(request, 0, &log),
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, interceptor.called)
|
||||
assert.Equal(t, http.StatusTeapot, responseWriter.Code)
|
||||
for _, tag := range testTags {
|
||||
assert.Empty(t, request.Header.Get(TagHeaderNamePrefix+tag.Name))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxySingleOrigin(t *testing.T) {
|
||||
log := zerolog.Nop()
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package quicktunnelauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var errQuickTunnelAuthCallbackValidationUnavailable = errors.New("broker assertion validation is not configured")
|
||||
|
||||
// QuickTunnelAuthHandler intercepts protected Quick Tunnel requests before
|
||||
// ingress selection.
|
||||
type QuickTunnelAuthHandler struct {
|
||||
stateManager *QuickTunnelAuthStateManager
|
||||
}
|
||||
|
||||
// NewQuickTunnelAuthHandler creates a pre-origin handler backed by the provided
|
||||
// browser authentication state manager.
|
||||
func NewQuickTunnelAuthHandler(stateManager *QuickTunnelAuthStateManager) (*QuickTunnelAuthHandler, error) {
|
||||
if stateManager == nil {
|
||||
return nil, errors.New("authentication state manager cannot be nil")
|
||||
}
|
||||
return &QuickTunnelAuthHandler{stateManager: stateManager}, nil
|
||||
}
|
||||
|
||||
// HandleHTTP redirects new browser logins and handles the reserved broker
|
||||
// callback without allowing either request to reach the origin.
|
||||
func (h *QuickTunnelAuthHandler) HandleHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
// private: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control#private
|
||||
// no-store: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control#no-store
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
// no-referrer: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#no-referrer_2
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
|
||||
if r == nil || r.URL == nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return errors.New("invalid protected Quick Tunnel request")
|
||||
}
|
||||
|
||||
if r.URL.EscapedPath() == QuickTunnelAuthCallbackPath {
|
||||
return h.handleCallback(w, r)
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return nil
|
||||
}
|
||||
|
||||
login, err := h.stateManager.BeginLogin(r)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(w, login.Cookie)
|
||||
w.Header().Set("Location", login.RedirectURL.String())
|
||||
w.WriteHeader(http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *QuickTunnelAuthHandler) handleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||
callback, err := h.stateManager.ConsumeCallback(r)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(w, callback.ClearCookie)
|
||||
// TODO(TUN-10801): Validate callback.Assertion against the broker JWKS and
|
||||
// bind its state and hostname claims before applying recipient rules.
|
||||
// TODO(TUN-10802): Issue the local session and redirect with HTTP 303 to
|
||||
// callback.ReturnPath. Until then, fail closed after consuming the state.
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
return errQuickTunnelAuthCallbackValidationUnavailable
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package quicktunnelauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewQuickTunnelAuthHandlerRejectsNilStateManager(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := NewQuickTunnelAuthHandler(nil)
|
||||
assert.Nil(t, handler)
|
||||
require.ErrorContains(t, err, "state manager cannot be nil")
|
||||
}
|
||||
|
||||
func TestQuickTunnelAuthHandlerRejectsInvalidRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := newTestQuickTunnelAuthStateManager(t)
|
||||
handler, err := NewQuickTunnelAuthHandler(manager)
|
||||
require.NoError(t, err)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
err = handler.HandleHTTP(response, nil)
|
||||
require.ErrorContains(t, err, "invalid protected Quick Tunnel request")
|
||||
assert.Equal(t, http.StatusBadRequest, response.Code)
|
||||
assert.Equal(t, http.StatusText(http.StatusBadRequest)+"\n", response.Body.String())
|
||||
}
|
||||
|
||||
func TestQuickTunnelAuthHandlerRedirectsLoginBeforeOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := newTestQuickTunnelAuthStateManager(t)
|
||||
handler, err := NewQuickTunnelAuthHandler(manager)
|
||||
require.NoError(t, err)
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"https://test-tunnel.trycloudflare.com/dashboard",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
err = handler.HandleHTTP(response, request)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusFound, response.Code)
|
||||
assert.Equal(t, "private, no-store", response.Header().Get("Cache-Control"))
|
||||
assert.Equal(t, "no-referrer", response.Header().Get("Referrer-Policy"))
|
||||
|
||||
location, err := url.Parse(response.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https", location.Scheme)
|
||||
assert.Equal(t, "login.trycloudflare.com", location.Host)
|
||||
assert.Equal(t, "/authorize", location.Path)
|
||||
assert.Equal(t, "test-tunnel.trycloudflare.com", location.Query().Get("hostname"))
|
||||
assert.NotEmpty(t, location.Query().Get("state"))
|
||||
assert.Len(t, response.Result().Cookies(), 1)
|
||||
}
|
||||
|
||||
func TestQuickTunnelAuthHandlerRejectsUnsafeMethodBeforeOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := newTestQuickTunnelAuthStateManager(t)
|
||||
handler, err := NewQuickTunnelAuthHandler(manager)
|
||||
require.NoError(t, err)
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://test-tunnel.trycloudflare.com/dashboard",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
err = handler.HandleHTTP(response, request)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusUnauthorized, response.Code)
|
||||
assert.Equal(t, http.StatusText(http.StatusUnauthorized)+"\n", response.Body.String())
|
||||
}
|
||||
|
||||
func TestQuickTunnelAuthHandlerHandlesInvalidCallbackLocally(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := newTestQuickTunnelAuthStateManager(t)
|
||||
handler, err := NewQuickTunnelAuthHandler(manager)
|
||||
require.NoError(t, err)
|
||||
request := newTestQuickTunnelAuthCallbackRequest(nil, "missing-state", "broker.assertion")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
err = handler.HandleHTTP(response, request)
|
||||
require.ErrorContains(t, err, "verify authentication-state cookie")
|
||||
assert.Equal(t, http.StatusBadRequest, response.Code)
|
||||
assert.Equal(t, http.StatusText(http.StatusBadRequest)+"\n", response.Body.String())
|
||||
}
|
||||
|
||||
func TestQuickTunnelAuthHandlerFailsClosedWithoutValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := newTestQuickTunnelAuthStateManager(t)
|
||||
handler, err := NewQuickTunnelAuthHandler(manager)
|
||||
require.NoError(t, err)
|
||||
login := beginTestQuickTunnelLogin(t, manager, "/dashboard")
|
||||
request := newTestQuickTunnelAuthCallbackRequest(login.Cookie, login.State, "broker.assertion")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
err = handler.HandleHTTP(response, request)
|
||||
require.ErrorIs(t, err, errQuickTunnelAuthCallbackValidationUnavailable)
|
||||
assert.Equal(t, http.StatusForbidden, response.Code)
|
||||
assert.Equal(t, http.StatusText(http.StatusForbidden)+"\n", response.Body.String())
|
||||
assert.Len(t, response.Result().Cookies(), 1)
|
||||
assert.Equal(t, -1, response.Result().Cookies()[0].MaxAge)
|
||||
}
|
||||
Reference in New Issue
Block a user