Merge remote-tracking branch 'origin/main' into feat/huma-api-docs

This commit is contained in:
Kyle Mendell
2026-07-13 11:00:13 -05:00
28 changed files with 413 additions and 216 deletions
+10 -4
View File
@@ -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,
+57 -27
View File
@@ -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
}
+25 -1
View File
@@ -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) {
+4 -3
View File
@@ -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 {
+12
View File
@@ -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 == "" {
+65
View File
@@ -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)
+12 -1
View File
@@ -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) {
+43 -6
View File
@@ -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",