feat: add support Cloudflare location headers

This commit is contained in:
Elias Schneider
2026-10-01 20:53:38 +02:00
parent 02359840f2
commit 1a91eaa98c
15 changed files with 316 additions and 47 deletions
+2 -2
View File
@@ -103,8 +103,8 @@ func Bootstrap(ctx context.Context) error {
// Migrate the pre-actor signup tokens into their actors, once the actor host is ready
services = append(services, actorsReady.Await(svc.userSignUpModule.RunSignupTokenMigration))
// These services are only registered in non-test mode
if common.EnvConfig.AppEnv != "test" {
// Only the GeoLite provider needs a background database refresher
if !common.EnvConfig.AppEnv.IsTest() && svc.geoLiteModule != nil {
// Refresh the GeoLite database (this is cached per each replica)
services = append(services, svc.geoLiteModule.Run)
}
@@ -124,6 +124,9 @@ func shouldTraceRequest(r *http.Request) bool {
}
func registerGlobalMiddleware(r *gin.Engine) {
if common.EnvConfig.CloudflareLocationHeaders {
r.Use(middleware.CloudflareLocationMiddleware())
}
r.Use(middleware.HeadMiddleware())
r.Use(middleware.NewCacheControlMiddleware().Add())
r.Use(middleware.NewCorsMiddleware().Add())
@@ -21,10 +21,70 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/geolite"
"github.com/pocket-id/pocket-id/backend/internal/iplocation"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/stretchr/testify/require"
)
func TestCloudflareLocationMiddlewareOnlyWhenEnabled(t *testing.T) {
previousConfig := common.EnvConfig
t.Cleanup(func() { common.EnvConfig = previousConfig })
gin.SetMode(gin.TestMode)
for _, enabled := range []bool{false, true} {
name := "disabled"
if enabled {
name = "enabled"
}
t.Run(name, func(t *testing.T) {
common.EnvConfig.CloudflareLocationHeaders = enabled
locator, geoLiteModule, err := initIPLocationResolver(t.Context(), nil, &common.EnvConfigSchema{
GeoLiteDBPath: filepath.Join(t.TempDir(), "missing.mmdb"),
CloudflareLocationHeaders: enabled,
})
require.NoError(t, err)
if enabled {
require.IsType(t, iplocation.NewCloudflareResolver(), locator)
require.Nil(t, geoLiteModule)
} else {
require.IsType(t, &geolite.Module{}, locator)
require.Same(t, geoLiteModule, locator)
}
// Exercise the real global middleware with a Cloudflare client IP instead of the proxy's IP
router := gin.New()
router.TrustedPlatform = "CF-Connecting-IP"
registerGlobalMiddleware(router)
router.GET("/api/test", func(c *gin.Context) {
country, city, err := locator.GetLocationByIP(c.Request.Context(), c.ClientIP())
require.NoError(t, err)
c.JSON(http.StatusOK, gin.H{"ip": c.ClientIP(), "country": country, "city": city})
})
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test", nil)
request.RemoteAddr = "192.168.1.1:1234"
request.Header.Set("cf-connecting-ip", "81.2.69.142")
request.Header.Set("cf-ipcountry", "CH")
request.Header.Set("cf-ipcity", "Zürich")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
var location map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &location))
require.Equal(t, "81.2.69.142", location["ip"])
if enabled {
require.Equal(t, "Switzerland", location["country"])
require.Equal(t, "Zürich", location["city"])
} else {
require.Empty(t, location["country"])
require.Empty(t, location["city"])
}
})
}
}
func TestRequestLoggerUsesStructuredErrorMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -17,6 +17,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
"github.com/pocket-id/pocket-id/backend/internal/environment"
"github.com/pocket-id/pocket-id/backend/internal/geolite"
"github.com/pocket-id/pocket-id/backend/internal/iplocation"
"github.com/pocket-id/pocket-id/backend/internal/ldapsync"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
@@ -33,6 +34,7 @@ type services struct {
appImagesService *service.AppImagesService
emailModule *email.Module
geoLiteModule *geolite.Module
ipLocator iplocation.Resolver
auditLogService *service.AuditLogService
jwtService *service.JwtService
userService *service.UserService
@@ -84,17 +86,13 @@ func initServices(
return nil, fmt.Errorf("failed to create email module: %w", err)
}
svc.geoLiteModule, err = geolite.New(ctx, geolite.Dependencies{
HTTPClient: httpClient,
DBPath: common.EnvConfig.GeoLiteDBPath,
DownloadURL: common.EnvConfig.GeoLiteDBUrl,
LicenseKey: common.EnvConfig.MaxMindLicenseKey,
})
// Select the location provider once so all consumers use the configured implementation
svc.ipLocator, svc.geoLiteModule, err = initIPLocationResolver(ctx, httpClient, &common.EnvConfig)
if err != nil {
return nil, fmt.Errorf("failed to create GeoLite module: %w", err)
return nil, fmt.Errorf("failed to create IP location resolver: %w", err)
}
svc.auditLogService = service.NewAuditLogService(db, svc.emailModule, svc.geoLiteModule, svc.appConfigService)
svc.auditLogService = service.NewAuditLogService(db, svc.emailModule, svc.ipLocator, svc.appConfigService)
svc.auditLogsModule, err = auditlogs.New(auditlogs.Dependencies{
DB: db,
Actors: actors,
@@ -132,7 +130,7 @@ func initServices(
Signer: svc.jwtService,
Reauth: svc.webauthnModule,
AuditLog: svc.auditLogService,
IPLocator: svc.geoLiteModule,
IPLocator: svc.ipLocator,
AppConfig: svc.appConfigService,
})
if err != nil {
@@ -262,3 +260,21 @@ func initServices(
return svc, nil
}
func initIPLocationResolver(ctx context.Context, httpClient *http.Client, config *common.EnvConfigSchema) (iplocation.Resolver, *geolite.Module, error) {
// Cloudflare locations need no database initialization or background refresh
if config.CloudflareLocationHeaders {
return iplocation.NewCloudflareResolver(), nil, nil
}
module, err := geolite.New(ctx, geolite.Dependencies{
HTTPClient: httpClient,
DBPath: config.GeoLiteDBPath,
DownloadURL: config.GeoLiteDBUrl,
LicenseKey: config.MaxMindLicenseKey,
})
if err != nil {
return nil, nil, err
}
return module, module, nil
}