Skip to content

Commit

Permalink
fix: remove index.html security metas, improve cf validation
Browse files Browse the repository at this point in the history
  • Loading branch information
petterip committed Nov 6, 2024
1 parent 9e85231 commit 72113c9
Show file tree
Hide file tree
Showing 14 changed files with 294 additions and 104 deletions.
14 changes: 12 additions & 2 deletions doc/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,19 @@ security:

### Cloudflare Access Authentication Bypass

Cloudflare Access provides an authentication layer that uses your existing identity providers, such as Google or GitHub accounts,
to control access to your applications. When using Cloudflare Access for authentication, you can configure BirdNET-Go to trust traffic coming through the Cloudflare tunnel. The system authenticates requests by validating the `Cf-Access-Jwt-Assertion` header containing a JWT token from Cloudflare.
Cloudflare Access provides an authentication layer that uses your existing identity providers, such as Google or GitHub accounts, to control access to your applications. When using Cloudflare Access for authentication, you can configure BirdNET-Go to trust traffic coming through the Cloudflare tunnel. The system authenticates requests by validating the `Cf-Access-Jwt-Assertion` header containing a JWT token from Cloudflare.

To add even more security, you can also require that the Cloudflare Team Domain Name and Policy audience are valid in the JWT token. Enable these by defining them in the `config.yaml` file:

```yaml
security:
allowcloudflarebypass:
enabled: true
teamdomain: "your-subdomain-of-cloudflareaccess.com"
audience: "your-policy-auddience"
```

See the following links for more information on Cloudflare Access:
- [Cloudflare tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/)
- [Create a remotely-managed tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel/)
- [Self-hosted applications](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/)
Expand Down
28 changes: 18 additions & 10 deletions internal/conf/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,17 @@ type SocialProvider struct {
UserId string // valid user id for OAuth2
}

type AllowSubnetBypass struct {
Enabled bool // true to enable subnet bypass
Subnet string // disable OAuth2 in subnet
}

type AllowCloudflareBypass struct {
Enabled bool // true to enable CF Access
TeamDomain string // Cloudflare team domain
Audience string // Cloudflare policy audience
}

// SecurityConfig handles all security-related settings and validations
// for the application, including authentication, TLS, and access control.
type Security struct {
Expand All @@ -239,16 +250,13 @@ type Security struct {
// Let's Encrypt. Requires Host to be set and port 80/443 access.
AutoTLS bool

RedirectToHTTPS bool // true to redirect to HTTPS
AllowSubnetBypass struct {
Enabled bool // true to enable subnet bypass
Subnet string // disable OAuth2 in subnet
}
AllowCloudflareBypass bool // disable OAuth2 in Cloudflare tunnel
BasicAuth BasicAuth // password authentication configuration
GoogleAuth SocialProvider // Google OAuth2 configuration
GithubAuth SocialProvider // Github OAuth2 configuration
SessionSecret string // secret for session cookie
RedirectToHTTPS bool // true to redirect to HTTPS
AllowSubnetBypass AllowSubnetBypass // subnet bypass configuration
AllowCloudflareBypass AllowCloudflareBypass // Cloudflare Access configuration
BasicAuth BasicAuth // password authentication configuration
GoogleAuth SocialProvider // Google OAuth2 configuration
GithubAuth SocialProvider // Github OAuth2 configuration
SessionSecret string // secret for session cookie
}

// Settings contains all configuration options for the BirdNET-Go application.
Expand Down
5 changes: 4 additions & 1 deletion internal/conf/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ security:
allowsubnetbypass:
enabled: false # true to disable OAuth in subnet
subnet: "" # comma-separated list of CIDR ranges (e.g., "192.168.1.0/24,10.0.0.0/8")
allowcftunnelbypass: false # true to disable OAuth for Cloudflare Tunnel requests
allowcloudflarebypass:
enabled: false # true to disable bypass for Cloudflare Tunnel
teamdomain: "" # Cloudflare Tunnel team domain
audience: "" # Cloudflare Tunnel policy audience
basicauth:
enabled: false # true to enable basic auth
password: "" # password hash for the settings interface
Expand Down
4 changes: 3 additions & 1 deletion internal/conf/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ func setDefaultConfig() {
viper.SetDefault("security.redirecttohttps", false)
viper.SetDefault("security.allowsubnetbypass.enabled", false)
viper.SetDefault("security.allowsubnetbypass.subnet", "")
viper.SetDefault("security.allowcloudflaretunnelbypass", false)
viper.SetDefault("security.allowcloudflarebypass.enabled", false)
viper.SetDefault("security.allowcloudflarebypass.teamdomain", "")
viper.SetDefault("security.allowcloudflarebypass.audience", "")

// Basic authentication configuration
viper.SetDefault("security.basic.enabled", false)
Expand Down
2 changes: 1 addition & 1 deletion internal/httpcontroller/auth_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func (s *Server) handleLogout(c echo.Context) error {
gothic.Logout(c.Response(), c.Request()) //nolint:errcheck

// Handle Cloudflare logout if enabled
if s.Settings.Security.AllowCloudflareBypass && s.CloudflareAccess.IsEnabled(c) {
if s.CloudflareAccess.IsEnabled(c) {
return s.CloudflareAccess.Logout(c)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpcontroller/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (s *Server) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if isProtectedRoute(c.Path()) {
// Check for Cloudflare bypass
if s.Settings.Security.AllowCloudflareBypass && s.CloudflareAccess.IsEnabled(c) {
if s.CloudflareAccess.IsEnabled(c) {
return next(c)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpcontroller/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func (s *Server) handlePageRequest(c echo.Context) error {
path := c.Path()
pageRoute, isPageRoute := s.pageRoutes[path]
partialRoute, isFragment := s.partialRoutes[path]
isCloudflare := s.Settings.Security.AllowCloudflareBypass && s.CloudflareAccess.IsEnabled(c)
isCloudflare := s.CloudflareAccess.IsEnabled(c)

// Return an error if route is unknown
if !isPageRoute && !isFragment {
Expand Down
5 changes: 2 additions & 3 deletions internal/httpcontroller/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func New(settings *conf.Settings, dataStore datastore.Interface, birdImageCache
BirdImageCache: birdImageCache,
AudioLevelChan: audioLevelChan,
DashboardSettings: &settings.Realtime.Dashboard,
OAuth2Server: security.NewOAuth2Server(settings),
OAuth2Server: security.NewOAuth2Server(),
CloudflareAccess: security.NewCloudflareAccess(),
}

Expand Down Expand Up @@ -107,8 +107,7 @@ func (s *Server) isAuthenticationEnabled(c echo.Context) bool {

func (s *Server) IsAccessAllowed(c echo.Context) bool {
// First check Cloudflare Access JWT
if s.Settings.Security.AllowCloudflareBypass && s.CloudflareAccess.IsEnabled(c) {
log.Printf("\033[1;35m*** IsAccessAllowed: Cloudflare Access token valid")
if s.CloudflareAccess.IsEnabled(c) {
return true
}

Expand Down
83 changes: 61 additions & 22 deletions internal/security/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"github.com/tphakala/birdnet-go/internal/conf"
)

type CloudflareAccessClaims struct {
Expand All @@ -30,24 +31,34 @@ type CloudflareAccessClaims struct {
type CloudflareAccess struct {
certs map[string]string
teamDomain string
audience string
certCache struct {
lastFetch time.Time
mutex sync.RWMutex
}
settings *conf.AllowCloudflareBypass
debug bool
}

func NewCloudflareAccess() *CloudflareAccess {
settings := conf.GetSettings()
cfBypass := settings.Security.AllowCloudflareBypass

return &CloudflareAccess{
certs: make(map[string]string),
certs: make(map[string]string),
teamDomain: cfBypass.TeamDomain,
audience: cfBypass.Audience,
certCache: struct {
lastFetch time.Time
mutex sync.RWMutex
}{
lastFetch: time.Time{},
},
settings: &cfBypass,
}
}

// fetchCertsIfNeeded fetches the certificates using a cache
func (ca *CloudflareAccess) fetchCertsIfNeeded(issuer string) error {
ca.certCache.mutex.RLock()
cacheAge := time.Since(ca.certCache.lastFetch)
Expand All @@ -73,9 +84,10 @@ func (ca *CloudflareAccess) fetchCertsIfNeeded(issuer string) error {
// fetchCerts fetches the certificates from Cloudflare
func (ca *CloudflareAccess) fetchCerts(issuer string) error {
certsURL := fmt.Sprintf("%s/cdn-cgi/access/certs", issuer)
log.Printf("Fetching Cloudflare certs from URL: %s", certsURL)
ca.Debug("Fetching Cloudflare certs from URL: %s", certsURL)

resp, err := http.Get(certsURL)

if err != nil {
return fmt.Errorf("failed to fetch Cloudflare certs: %w", err)
}
Expand All @@ -90,21 +102,26 @@ func (ca *CloudflareAccess) fetchCerts(issuer string) error {
Cert string `json:"cert"`
} `json:"public_certs"`
}

if err := json.NewDecoder(resp.Body).Decode(&certsResponse); err != nil {
return fmt.Errorf("failed to decode certs response: %w", err)
}

// Store the certificates with kids as keys
for _, cert := range certsResponse.PublicCerts {
ca.certs[cert.Kid] = cert.Cert
log.Printf("Added certificate with Kid: %s", cert.Kid)
ca.Debug("Added certificate with Kid: %s", cert.Kid)
}

return nil
}

// IsEnabled returns true if Cloudflare Access is enabled
func (ca *CloudflareAccess) IsEnabled(c echo.Context) bool {

if !ca.settings.Enabled {
return false
}

claims, err := ca.VerifyAccessJWT(c.Request())
if err == nil && claims != nil {
return true
Expand All @@ -116,7 +133,7 @@ func (ca *CloudflareAccess) IsEnabled(c echo.Context) bool {
func (ca *CloudflareAccess) VerifyAccessJWT(r *http.Request) (*CloudflareAccessClaims, error) {
jwtToken := r.Header.Get("Cf-Access-Jwt-Assertion")
if jwtToken == "" {
log.Println("No Cloudflare Access JWT found")
ca.Debug("No Cloudflare Access JWT found")
return nil, fmt.Errorf("no Cloudflare Access JWT found")
}

Expand All @@ -125,24 +142,31 @@ func (ca *CloudflareAccess) VerifyAccessJWT(r *http.Request) (*CloudflareAccessC
claims := &CloudflareAccessClaims{}
token, _, err := parser.ParseUnverified(jwtToken, claims)
if err != nil {
log.Printf("Failed to parse JWT: %v", err)
ca.Debug("Failed to parse JWT: %v", err)
return nil, fmt.Errorf("failed to parse JWT: %w", err)
}

// Extract team domain from issuer URL
if claims.Issuer != "" {
parsedIssuer, err := url.Parse(claims.Issuer)
if err != nil {
log.Printf("Invalid issuer URL: %v", err)
ca.Debug("Invalid issuer URL: %v", err)
return nil, fmt.Errorf("invalid issuer URL: %w", err)
}
ca.teamDomain = strings.Split(parsedIssuer.Hostname(), ".")[0]

// Validate team domain if configured
if ca.settings.TeamDomain != "" {
if ca.teamDomain != ca.settings.TeamDomain {
return nil, fmt.Errorf("team domain mismatch")
}
}
}

// Verify the JWT with the public key
kid, ok := token.Header["kid"].(string)
if !ok {
log.Println("No key ID in JWT header")
ca.Debug("No key ID in JWT header")
return nil, fmt.Errorf("no key ID in JWT header")
}

Expand All @@ -154,7 +178,7 @@ func (ca *CloudflareAccess) VerifyAccessJWT(r *http.Request) (*CloudflareAccessC
cert := ca.certs[kid]
pubKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cert))
if err != nil {
log.Printf("Failed to parse public key: %v", err)
ca.Debug("Failed to parse public key: %v", err)
return nil, fmt.Errorf("failed to parse public key: %w", err)
}

Expand All @@ -164,35 +188,39 @@ func (ca *CloudflareAccess) VerifyAccessJWT(r *http.Request) (*CloudflareAccessC
})

if err != nil {
log.Printf("Invalid JWT: %v", err)
ca.Debug("Invalid JWT: %v", err)
return nil, fmt.Errorf("invalid JWT: %w", err)
}

if !token.Valid {
log.Println("Token is not valid")
ca.Debug("Token is not valid")
return nil, fmt.Errorf("token is not valid")
}

if err := claims.Valid(); err != nil {
log.Printf("Invalid claims: %v", err)
ca.Debug("Invalid claims: %v", err)
return nil, fmt.Errorf("invalid claims: %w", err)
}

now := time.Now().Unix()
if claims.ExpiresAt < now {
log.Println("Token expired")
return nil, fmt.Errorf("token expired")
}
if claims.NotBefore > now {
log.Println("Token not yet valid")
return nil, fmt.Errorf("token not yet valid")
// Validate audience if configured
if ca.settings.Audience != "" {
audienceValid := false
for _, aud := range claims.Audience {
if aud == ca.settings.Audience {
audienceValid = true
break
}
}
if !audienceValid {
return nil, fmt.Errorf("audience mismatch")
}
}

if claims.Type != "app" {
log.Printf("Invalid token type: %s", claims.Type)
ca.Debug("Invalid token type: %s", claims.Type)
return nil, fmt.Errorf("invalid token type: %s", claims.Type)
}

log.Println("Cloudflare Access JWT successfully verified")
return claims, nil
}

Expand Down Expand Up @@ -238,6 +266,7 @@ func (ca *CloudflareAccess) Logout(c echo.Context) error {
Value: "",
Expires: time.Now().Add(-time.Hour),
})
ca.Debug("Logged out from Cloudflare Access")

// Redirect to GetLogoutURL
return c.Redirect(http.StatusFound, ca.GetLogoutURL())
Expand All @@ -246,3 +275,13 @@ func (ca *CloudflareAccess) Logout(c echo.Context) error {
func (ca *CloudflareAccess) GetLogoutURL() string {
return fmt.Sprintf("https://%s.cloudflareaccess.com/cdn-cgi/access/logout", ca.teamDomain)
}

func (ca *CloudflareAccess) Debug(format string, v ...interface{}) {
if !ca.debug {
if len(v) == 0 {
log.Print(format)
} else {
log.Printf(format, v...)
}
}
}
Loading

0 comments on commit 72113c9

Please sign in to comment.