-
Notifications
You must be signed in to change notification settings - Fork 910
Improve SAMLResponse validation in SSO callbacks #47463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| * Improved SAMLResponse validation by rejecting large responses, deeply nested documents, or documents with too many nodes. | ||
| * Added rate limiting to the SSO callback endpoint. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,6 +67,7 @@ func checkLicenseExpiration(svc fleet.Service) func(context.Context, http.Respon | |
| type extraHandlerOpts struct { | ||
| loginRateLimit *throttled.Rate | ||
| mdmSsoRateLimit *throttled.Rate | ||
| ssoRateLimit *throttled.Rate | ||
| httpSigVerifier mux.MiddlewareFunc | ||
| } | ||
|
|
||
|
|
@@ -87,6 +88,15 @@ func WithMdmSsoRateLimit(r throttled.Rate) ExtraHandlerOption { | |
| } | ||
| } | ||
|
|
||
| // WithSsoRateLimit configures the rate of the SSO callback's dedicated rate | ||
| // limit bucket (the rate defaults to the login rate limit otherwise; the bucket | ||
| // is always separate from the login bucket). | ||
| func WithSsoRateLimit(r throttled.Rate) ExtraHandlerOption { | ||
| return func(o *extraHandlerOpts) { | ||
| o.ssoRateLimit = &r | ||
| } | ||
| } | ||
|
|
||
| func WithHTTPSigVerifier(m mux.MiddlewareFunc) ExtraHandlerOption { | ||
| return func(o *extraHandlerOpts) { | ||
| o.httpSigVerifier = m | ||
|
|
@@ -1147,8 +1157,38 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC | |
| ne.WithCustomMiddleware(orgLogoLimiter). | ||
| GET("/api/_version_/fleet/logo", getOrgLogoEndpoint, getOrgLogoRequest{}) | ||
|
|
||
| // Rate limiters shared across the login/SSO endpoints. These are defined | ||
| // here (ahead of the password-login registrations below) so the | ||
| // unauthenticated SSO callback can reuse the same login bucket. | ||
| limiter := ratelimit.NewMiddleware(limitStore) | ||
|
|
||
| // By default, MDM SSO shares the login rate limit bucket; if MDM SSO limit is overridden, MDM SSO gets its | ||
| // own rate limit bucket. | ||
| loginRateLimit := throttled.PerMin(10) | ||
| if extra.loginRateLimit != nil { | ||
| loginRateLimit = *extra.loginRateLimit | ||
| } | ||
| loginLimiter := limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9}) | ||
| mdmSsoLimiter := loginLimiter | ||
| if extra.mdmSsoRateLimit != nil { | ||
| mdmSsoLimiter = limiter.Limit("mdm_sso", throttled.RateQuota{MaxRate: *extra.mdmSsoRateLimit, MaxBurst: 9}) | ||
| } | ||
| // The SSO callback gets its own dedicated bucket (separate from the login | ||
| // bucket) so a flood on the unauthenticated callback can't exhaust the | ||
| // rate-limit budget that legitimate password logins depend on. The rate | ||
| // defaults to the login rate unless explicitly overridden. | ||
| ssoRateLimit := loginRateLimit | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to do the same thing for the mdmSsoLimiter? Right now both the logic and mdm sso limiter share the same bucket
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to keep this new rate limiting separate to reduce breaking other endpoints (this new rate limit will be separate from login/MDM-SSO endpoints) |
||
| if extra.ssoRateLimit != nil { | ||
| ssoRateLimit = *extra.ssoRateLimit | ||
| } | ||
| ssoLimiter := limiter.Limit("sso", throttled.RateQuota{MaxRate: ssoRateLimit, MaxBurst: 9}) | ||
|
|
||
| ne.POST("/api/v1/fleet/sso", initiateSSOEndpoint, initiateSSORequest{}) | ||
| ne.POST("/api/v1/fleet/sso/callback", makeCallbackSSOEndpoint(config.Server.URLPrefix), callbackSSORequest{}) | ||
| // The SSO callback is unauthenticated and internet-reachable. Rate-limit it | ||
| // (dedicated bucket) and cap the body to keep pre-auth attacks surface small. | ||
| ne.WithCustomMiddleware(ssoLimiter). | ||
| WithRequestBodySizeLimit(fleet.MaxSSOCallbackSize). | ||
| POST("/api/v1/fleet/sso/callback", makeCallbackSSOEndpoint(config.Server.URLPrefix), callbackSSORequest{}) | ||
| ne.GET("/api/v1/fleet/sso", settingsSSOEndpoint, nil) | ||
|
|
||
| // the websocket distributed query results endpoint is a bit different - the | ||
|
|
@@ -1160,23 +1200,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC | |
| makeStreamDistributedQueryCampaignResultsHandler(config.Server, svc, logger)) | ||
|
|
||
| quota := throttled.RateQuota{MaxRate: throttled.PerHour(10), MaxBurst: forgotPasswordRateLimitMaxBurst} | ||
| limiter := ratelimit.NewMiddleware(limitStore) | ||
| ne. | ||
| WithCustomMiddleware(limiter.Limit("forgot_password", quota)). | ||
| POST("/api/_version_/fleet/forgot_password", forgotPasswordEndpoint, forgotPasswordRequest{}) | ||
|
|
||
| // By default, MDM SSO shares the login rate limit bucket; if MDM SSO limit is overridden, MDM SSO gets its | ||
| // own rate limit bucket. | ||
| loginRateLimit := throttled.PerMin(10) | ||
| if extra.loginRateLimit != nil { | ||
| loginRateLimit = *extra.loginRateLimit | ||
| } | ||
| loginLimiter := limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9}) | ||
| mdmSsoLimiter := loginLimiter | ||
| if extra.mdmSsoRateLimit != nil { | ||
| mdmSsoLimiter = limiter.Limit("mdm_sso", throttled.RateQuota{MaxRate: *extra.mdmSsoRateLimit, MaxBurst: 9}) | ||
| } | ||
|
|
||
| ne.WithCustomMiddleware(loginLimiter). | ||
| POST("/api/_version_/fleet/login", loginEndpoint, fleet.LoginRequest{}) | ||
| ne.WithCustomMiddleware(limiter.Limit("mfa", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). | ||
|
|
@@ -1191,7 +1218,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC | |
|
|
||
| neAppleMDM.WithCustomMiddleware(mdmSsoLimiter). | ||
| POST("/api/_version_/fleet/mdm/sso", initiateMDMSSOEndpoint, initiateMDMSSORequest{}) | ||
| // Same posture as the regular SSO callback: rate-limited (already) plus a | ||
| // tight body cap to keep pre-auth attacks surface small. | ||
| ne.WithCustomMiddleware(mdmSsoLimiter). | ||
| WithRequestBodySizeLimit(fleet.MaxSSOCallbackSize). | ||
| POST("/api/_version_/fleet/mdm/sso/callback", callbackMDMSSOEndpoint, callbackMDMSSORequest{}) | ||
|
|
||
| // Register all deprecated URL path aliases from the declarative table. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,10 +8,22 @@ import ( | |
| "slices" | ||
| "strings" | ||
|
|
||
| "github.com/beevik/etree" | ||
| "github.com/crewjam/saml" | ||
| "github.com/fleetdm/fleet/v4/server/fleet" | ||
| ) | ||
|
|
||
| const ( | ||
| // maxSAMLResponseDepth bounds how deeply nested the SAMLResponse XML may | ||
| // be. Legitimate SAML responses are shallow; deep nesting is the signature | ||
| // of a canonicalization bomb, which runs before any certificate or signature | ||
| // check on the unauthenticated SSO callback endpoints. | ||
| maxSAMLResponseDepth = 100 | ||
| // maxSAMLResponseElements bounds the total number of XML elements in the | ||
| // SAMLResponse, for the same reason (a bomb can be wide rather than deep). | ||
| maxSAMLResponseElements = 5000 | ||
| ) | ||
|
|
||
| // Since there's not a standard for display names, I have collected the most | ||
| // commonly used attribute names for it. | ||
| // | ||
|
|
@@ -109,8 +121,49 @@ func validateAudiences(assertion *saml.Assertion, expectedAudiences []string) er | |
| return fmt.Errorf("wrong audience: %+v", assertion.Conditions.AudienceRestrictions) | ||
| } | ||
|
|
||
| // validateSAMLResponseShape parses the decoded SAMLResponse XML and rejects | ||
| // documents that are excessively deep or have too many elements before they | ||
| // reach goxmldsig's pre-signature canonicalization, which (as of the time of writing) | ||
| // has no traversal limit of its own. | ||
| func validateSAMLResponseShape(samlResponse []byte) error { | ||
| doc := etree.NewDocument() | ||
| if err := doc.ReadFromBytes(samlResponse); err != nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. one minor note here is that this loads the whole payload into memory, but given the current guards at the middleware layer, an attack would consume at most 256 KB * 10 = ~2.5 MB per minute which should be ok.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, both the rate limit + size cap should keep things safe. |
||
| return fmt.Errorf("parsing SAMLResponse XML: %w", err) | ||
| } | ||
| root := doc.Root() | ||
| if root == nil { | ||
| return errors.New("SAMLResponse has no root element") | ||
| } | ||
|
|
||
| count := 0 | ||
| var walk func(el *etree.Element, depth int) error | ||
| walk = func(el *etree.Element, depth int) error { | ||
| if depth > maxSAMLResponseDepth { | ||
| return fmt.Errorf("SAMLResponse exceeds maximum nesting depth of %d", maxSAMLResponseDepth) | ||
| } | ||
| count++ | ||
| if count > maxSAMLResponseElements { | ||
| return fmt.Errorf("SAMLResponse exceeds maximum element count of %d", maxSAMLResponseElements) | ||
| } | ||
| for _, child := range el.ChildElements() { | ||
| if err := walk(child, depth+1); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
| return walk(root, 1) | ||
| } | ||
|
|
||
| // ParseAndVerifySAMLResponse runs the parsing and validation of SAMLResponses. | ||
| func ParseAndVerifySAMLResponse(samlProvider *saml.ServiceProvider, samlResponse []byte, requestID string, acsURL *url.URL) (fleet.Auth, error) { | ||
| // Reject oversized/over-nested documents before handing them to | ||
| // crewjam/saml -> goxmldsig, whose pre-signature canonicalization is (at the time of writing) | ||
| // unbounded and runs without authentication. | ||
| if err := validateSAMLResponseShape(samlResponse); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| verifiedAssertion, err := samlProvider.ParseXMLResponse(samlResponse, []string{requestID}, *acsURL) | ||
| if err != nil { | ||
| if samlErr, ok := err.(*saml.InvalidResponseError); ok { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.