mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-10 11:49:05 +02:00
Merge remote-tracking branch 'origin/main' into feat/huma-api-docs
This commit is contained in:
@@ -70,7 +70,10 @@ func initEngine() (*gin.Engine, error) {
|
||||
|
||||
r := gin.New()
|
||||
initLogger(r)
|
||||
configureEngine(r)
|
||||
err := configureEngine(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registerGlobalMiddleware(r)
|
||||
|
||||
return r, nil
|
||||
@@ -88,9 +91,10 @@ func setGinMode() {
|
||||
}
|
||||
}
|
||||
|
||||
func configureEngine(r *gin.Engine) {
|
||||
if !common.EnvConfig.TrustProxy {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
func configureEngine(r *gin.Engine) error {
|
||||
err := r.SetTrustedProxies(common.EnvConfig.TrustProxy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to configure trusted proxies: %w", err)
|
||||
}
|
||||
|
||||
if common.EnvConfig.TrustedPlatform != "" {
|
||||
@@ -101,6 +105,8 @@ func configureEngine(r *gin.Engine) {
|
||||
common.Name,
|
||||
otelgin.WithFilter(shouldTraceRequest)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldTraceRequest reports whether an incoming request should be traced.
|
||||
|
||||
@@ -88,9 +88,10 @@ func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClien
|
||||
DB: db,
|
||||
HTTPClient: httpClient,
|
||||
Config: oidc.Config{
|
||||
BaseURL: common.EnvConfig.AppURL,
|
||||
TokenBaseURL: common.EnvConfig.AppURL,
|
||||
Secret: common.EnvConfig.EncryptionKey,
|
||||
BaseURL: common.EnvConfig.AppURL,
|
||||
TokenBaseURL: common.EnvConfig.AppURL,
|
||||
Secret: common.EnvConfig.EncryptionKey,
|
||||
AllowInsecureCallbackURLs: common.EnvConfig.AllowInsecureCallbackURLs,
|
||||
},
|
||||
Signer: svc.jwtService,
|
||||
CustomClaims: svc.customClaimService,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
|
||||
type AppEnv string
|
||||
type DbProvider string
|
||||
type TrustProxyConfig []string
|
||||
|
||||
const (
|
||||
// TracerName should be passed to otel.Tracer, trace.SpanFromContext when creating custom spans.
|
||||
@@ -38,21 +40,22 @@ const (
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
|
||||
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
||||
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
|
||||
DbProvider DbProvider
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||
TrustProxy bool `env:"TRUST_PROXY"`
|
||||
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
|
||||
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
|
||||
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
|
||||
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
|
||||
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
|
||||
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
||||
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
|
||||
DbProvider DbProvider
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||
TrustProxy TrustProxyConfig `env:"TRUST_PROXY"`
|
||||
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
|
||||
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
AllowInsecureCallbackURLs bool `env:"ALLOW_INSECURE_CALLBACK_URLS"`
|
||||
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
|
||||
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
|
||||
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
|
||||
|
||||
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
@@ -97,18 +100,19 @@ func init() {
|
||||
|
||||
func defaultConfig() EnvConfigSchema {
|
||||
return EnvConfigSchema{
|
||||
AppEnv: AppEnvProduction,
|
||||
LogLevel: "info",
|
||||
DbProvider: "sqlite",
|
||||
FileBackend: "filesystem",
|
||||
AuditLogRetentionDays: 90,
|
||||
AppURL: AppUrl,
|
||||
Port: "1411",
|
||||
Host: "0.0.0.0",
|
||||
ActorsPort: "1414",
|
||||
ActorsHost: "0.0.0.0",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
AppEnv: AppEnvProduction,
|
||||
LogLevel: "info",
|
||||
DbProvider: "sqlite",
|
||||
FileBackend: "filesystem",
|
||||
AuditLogRetentionDays: 90,
|
||||
AllowInsecureCallbackURLs: true, // TODO: Default to false in major v3
|
||||
AppURL: AppUrl,
|
||||
Port: "1411",
|
||||
Host: "0.0.0.0",
|
||||
ActorsPort: "1414",
|
||||
ActorsHost: "0.0.0.0",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,3 +394,29 @@ func (a AppEnv) IsProduction() bool {
|
||||
func (a AppEnv) IsTest() bool {
|
||||
return a == AppEnvTest
|
||||
}
|
||||
|
||||
func (config *TrustProxyConfig) UnmarshalText(text []byte) error {
|
||||
value := strings.TrimSpace(string(text))
|
||||
|
||||
// Support boolean values for completely enabling or disabling trust proxy
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err == nil {
|
||||
if enabled {
|
||||
*config = TrustProxyConfig{"0.0.0.0/0", "::/0"}
|
||||
} else {
|
||||
*config = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize and validate each explicit proxy before the server starts
|
||||
proxies := strings.Split(value, ",")
|
||||
for i, proxy := range proxies {
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
proxies[i] = proxy
|
||||
}
|
||||
|
||||
*config = proxies
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -119,12 +119,36 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
t.Setenv("TRACING_ENABLED", "false")
|
||||
t.Setenv("TRUST_PROXY", "true")
|
||||
t.Setenv("ANALYTICS_DISABLED", "false")
|
||||
t.Setenv("ALLOW_INSECURE_CALLBACK_URLS", "false")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, EnvConfig.UiConfigDisabled)
|
||||
assert.True(t, EnvConfig.TrustProxy)
|
||||
assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.TrustProxy)
|
||||
assert.False(t, EnvConfig.AnalyticsDisabled)
|
||||
assert.False(t, EnvConfig.AllowInsecureCallbackURLs)
|
||||
})
|
||||
|
||||
t.Run("should parse trusted proxy IP addresses and CIDR ranges", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("TRUST_PROXY", "10.0.0.0/8, 192.168.1.10, ::1/128")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TrustProxyConfig{"10.0.0.0/8", "192.168.1.10", "::1/128"}, EnvConfig.TrustProxy)
|
||||
})
|
||||
|
||||
t.Run("should disable trusted proxies when set to false", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("TRUST_PROXY", "false")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, EnvConfig.TrustProxy)
|
||||
})
|
||||
|
||||
t.Run("should allow insecure callback URLs by default", func(t *testing.T) {
|
||||
assert.True(t, defaultConfig().AllowInsecureCallbackURLs)
|
||||
})
|
||||
|
||||
t.Run("should default audit log retention days to 90", func(t *testing.T) {
|
||||
|
||||
@@ -15,9 +15,10 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
TokenBaseURL string
|
||||
Secret []byte
|
||||
BaseURL string
|
||||
TokenBaseURL string
|
||||
Secret []byte
|
||||
AllowInsecureCallbackURLs bool
|
||||
}
|
||||
|
||||
type TokenSigner interface {
|
||||
|
||||
@@ -49,6 +49,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
IgnoreUnknownScopes: true,
|
||||
AudienceMatchingStrategy: fosite.ExactAudienceMatchingStrategy,
|
||||
RedirectURIMatcher: matchRedirectURI,
|
||||
RedirectSecureChecker: redirectSecureChecker(config.AllowInsecureCallbackURLs),
|
||||
EnforcePKCEForPublicClients: true,
|
||||
EnablePKCEPlainChallengeMethod: true,
|
||||
SupportedRequestObjectSigningAlgorithms: []string{"none"},
|
||||
@@ -111,6 +112,17 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
}, nil
|
||||
}
|
||||
|
||||
func redirectSecureChecker(allowInsecureCallbackURLs bool) func(context.Context, *url.URL) bool {
|
||||
return func(ctx context.Context, redirectURI *url.URL) bool {
|
||||
if allowInsecureCallbackURLs || fosite.IsRedirectURISecure(ctx, redirectURI) {
|
||||
return true
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "HTTP callback URL rejected; set ALLOW_INSECURE_CALLBACK_URLS=true to allow it", "callback_url", redirectURI.Redacted())
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func matchRedirectURI(rawurl string, client fosite.Client) (*url.URL, error) {
|
||||
redirectURI, err := fosite.MatchRedirectURIWithClientRedirectURIs(rawurl, client)
|
||||
if err == nil || rawurl == "" {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -85,6 +86,70 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) {
|
||||
require.Equal(t, "test-key-id", header["kid"])
|
||||
}
|
||||
|
||||
func TestRedirectSecureChecker(t *testing.T) {
|
||||
loopbackRedirectURI, err := url.Parse("http://127.0.0.1:49813/callback")
|
||||
require.NoError(t, err)
|
||||
|
||||
checker := redirectSecureChecker(false)
|
||||
require.True(t, checker(t.Context(), loopbackRedirectURI))
|
||||
}
|
||||
|
||||
func TestProviderInsecureCallbackURLCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowInsecureCallbackURLs bool
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "allows HTTP callback URLs when compatibility is enabled",
|
||||
allowInsecureCallbackURLs: true,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "rejects HTTP callback URLs when compatibility is disabled",
|
||||
allowInsecureCallbackURLs: false,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
Name: "Test Client",
|
||||
CallbackURLs: model.UrlList{"http://client.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
AllowInsecureCallbackURLs: tt.allowInsecureCallbackURLs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodGet,
|
||||
"/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri=http://client.example.com/callback",
|
||||
nil,
|
||||
)
|
||||
authorizeRequest, err := provider.NewAuthorizeRequest(req.Context(), req)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = provider.NewAuthorizeResponse(t.Context(), authorizeRequest, NewEmptySession())
|
||||
if tt.expectSuccess {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorIs(t, err, fosite.ErrInvalidRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
@@ -56,7 +57,17 @@ func IsTailscaleIP(ip net.IP) bool {
|
||||
}
|
||||
|
||||
func IsPrivateIP(ip net.IP) bool {
|
||||
return IsLocalhostIP(ip) || IsPrivateLanIP(ip) || IsTailscaleIP(ip) || IsLocalIPv6(ip)
|
||||
if IsLocalhostIP(ip) || IsPrivateLanIP(ip) || IsTailscaleIP(ip) || IsLocalIPv6(ip) {
|
||||
return true
|
||||
}
|
||||
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified()
|
||||
}
|
||||
|
||||
func IsURLPrivate(ctx context.Context, u *url.URL) (bool, error) {
|
||||
|
||||
@@ -110,12 +110,19 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"127.0.0.1", true}, // localhost
|
||||
{"192.168.1.1", true}, // private LAN
|
||||
{"100.64.0.1", true}, // Tailscale
|
||||
{"fd00::1", true}, // local IPv6
|
||||
{"8.8.8.8", false}, // public IPv4
|
||||
{"2001:4860:4860::8888", false}, // public IPv6
|
||||
{"127.0.0.1", true}, // localhost
|
||||
{"192.168.1.1", true}, // private LAN
|
||||
{"100.64.0.1", true}, // Tailscale
|
||||
{"169.254.169.254", true}, // IPv4 link-local
|
||||
{"169.254.170.2", true}, // IPv4 link-local
|
||||
{"::ffff:169.254.169.254", true}, // IPv4-mapped link-local
|
||||
{"fe80::1", true}, // IPv6 link-local
|
||||
{"ff02::1", true}, // IPv6 link-local multicast
|
||||
{"0.0.0.0", true}, // IPv4 unspecified
|
||||
{"::", true}, // IPv6 unspecified
|
||||
{"fd00::1", true}, // private IPv6
|
||||
{"8.8.8.8", false}, // public IPv4
|
||||
{"2001:4860:4860::8888", false}, // public IPv6
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -231,6 +238,36 @@ func TestIsURLPrivate(t *testing.T) {
|
||||
expectPriv: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "IPv4 link-local metadata IP",
|
||||
urlStr: "http://169.254.169.254",
|
||||
expectPriv: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "IPv4-mapped link-local metadata IP",
|
||||
urlStr: "http://[::ffff:169.254.169.254]",
|
||||
expectPriv: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "IPv6 link-local IP",
|
||||
urlStr: "http://[fe80::1]",
|
||||
expectPriv: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "IPv4 unspecified IP",
|
||||
urlStr: "http://0.0.0.0",
|
||||
expectPriv: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "IPv6 unspecified IP",
|
||||
urlStr: "http://[::]",
|
||||
expectPriv: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "public IP - Google DNS",
|
||||
urlStr: "http://8.8.8.8",
|
||||
|
||||
Reference in New Issue
Block a user