Skip to content
Open
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
24 changes: 24 additions & 0 deletions token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,23 @@ const (
appAUDHeader = "CF-Access-Aud"
AccessLoginWorkerPath = "/cdn-cgi/access/login"
AccessAuthorizedWorkerPath = "/cdn-cgi/access/authorized"
cloudflareAccessSuffix = ".cloudflareaccess.com"
)

// isCloudflareAccessHost reports whether host is a Cloudflare Access team
// hostname (*.cloudflareaccess.com). Used by GetAppInfo to verify that a
// response carrying CF-Access-Domain / CF-Access-Aud values actually came
// from the Cloudflare Access edge rather than from whatever host the user
// originally invoked cloudflared against.
func isCloudflareAccessHost(host string) bool {
// Drop a trailing port if present.
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
host = strings.ToLower(host)
return strings.HasSuffix(host, cloudflareAccessSuffix) && len(host) > len(cloudflareAccessSuffix)
}

var (
userAgent = "DEV"
signatureAlgs = []jose.SignatureAlgorithm{jose.RS256}
Expand Down Expand Up @@ -385,6 +400,15 @@ func GetAppInfo(reqURL *url.URL) (*AppInfo, error) {
var aud string
location := resp.Request.URL
if strings.Contains(location.Path, AccessLoginWorkerPath) {
// The redirect chain stopped at a URL whose path matches the
// AccessLoginWorkerPath substring. That predicate alone does not
// guarantee the response actually came from Cloudflare — any host can
// expose a /cdn-cgi/access/login path. Verify the host belongs to the
// Cloudflare Access edge (*.cloudflareaccess.com) before treating
// CF-Access-Domain and the ?kid= query parameter as authoritative.
if !isCloudflareAccessHost(location.Hostname()) {
return nil, fmt.Errorf("AppInfo redirect endpoint served by non-Cloudflare host %q; refusing to trust response headers", location.Hostname())
}
aud = resp.Request.URL.Query().Get("kid")
if aud == "" {
return nil, errors.New("Empty app aud")
Expand Down
61 changes: 61 additions & 0 deletions token/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package token
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)

Expand Down Expand Up @@ -133,3 +135,62 @@ func TestJwtPayloadUnmarshal_FailsWhenAudIsOmitted(t *testing.T) {
t.Errorf("Expected %v, got %v", wantErr, err)
}
}

func TestIsCloudflareAccessHost(t *testing.T) {
cases := []struct {
host string
want bool
}{
{"team.cloudflareaccess.com", true},
{"team.cloudflareaccess.com:8443", true},
{"TEAM.cloudflareaccess.com", true},
{"sub.team.cloudflareaccess.com", true},
{"cloudflareaccess.com", false},
{".cloudflareaccess.com", false},
{"attacker.com", false},
{"127.0.0.1", false},
{"127.0.0.1:8080", false},
{"cloudflareaccess.com.attacker.com", false},
{"team-cloudflareaccess.com", false},
{"", false},
}
for _, c := range cases {
if got := isCloudflareAccessHost(c.host); got != c.want {
t.Errorf("isCloudflareAccessHost(%q) = %v, want %v", c.host, got, c.want)
}
}
}

func TestGetAppInfo_RejectsRedirectFromNonCloudflareHost(t *testing.T) {
// Stand up a server that mimics the attacker shape: a HEAD to /
// redirects to /cdn-cgi/access/login?kid=AUD on the same host, which
// returns 200 with CF-Access-Domain set. Without host validation, the
// (AppDomain, AppAUD) fields would come from this attacker-controlled
// response. With host validation, GetAppInfo must refuse it.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/cdn-cgi/access/login?kid=AUD123", http.StatusFound)
return
}
if strings.Contains(r.URL.Path, AccessLoginWorkerPath) {
w.Header().Set(appDomainHeader, "victim-app.example.com")
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()

reqURL, err := url.Parse(srv.URL + "/")
if err != nil {
t.Fatalf("parse server URL: %v", err)
}

appInfo, err := GetAppInfo(reqURL)
if err == nil {
t.Fatalf("GetAppInfo accepted response from non-Cloudflare host; got appInfo=%+v", appInfo)
}
if !strings.Contains(err.Error(), "non-Cloudflare host") {
t.Fatalf("expected non-Cloudflare host error, got: %v", err)
}
}