fix(permissions): access-denied 403 page + auth/authz split + SAML loop-guard (#32365) - #36590
Conversation
…op-guard (#32365) Separate authentication (401 -> login) from authorization (403 -> Access Denied, never re-authenticate) so an authenticated-but-unauthorized front-end user gets a clean 403 instead of a 401 login redirect. Add a default Access Denied 403 page with a provider-agnostic SSO sign-out link, and a deterministic SAML loop-guard that returns a 403 when an authenticated user is bounced to the IdP for a destination they can never access (e.g. a front-end-only SAML user sent to /dotAdmin), breaking the infinite redirect loop. - SecurityUtils.sendPermissionDenied centralizes the 401-vs-403 decision; adopted by BinaryExporterServlet, SpeedyAssetServlet and CMSUrlUtil. - custom-error-page.jsp renders a 403 Access Denied page with an SSO/native sign-out link. - SamlWebInterceptor breaks the auth-redirect loop deterministically (isAutoLogin && isNotLogged -> 403), independent of session persistence. Refs: #36541 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @dsolistorres's task in 7m 3s —— View job 🔍 dotCMS Backend Review
All four review passes came back clean on the latest commit (
The consolidated findings were posted to the sticky backend-review comment (updated in place, now showing "no issues found"), and a formal ✅ approving GitHub review was submitted, superseding the earlier request-changes review. |
|
✅ dotCMS Backend Review: no issues found. All four review passes (security, database, Java standards, REST API) came back clean on the latest commit (
Next steps
|
…itted() (#32365) Address PR review feedback (#36590): - Add SamlWebInterceptorLoopGuardTest: asserts the loop-guard returns 403 + SKIP_NO_CHAIN for an authenticated-but-unauthorized user, and writes nothing when the response is already committed. - Guard response.sendError(SC_FORBIDDEN) with response.isCommitted(), matching the pattern established by SecurityUtils.sendPermissionDenied. - Document the guard's self-healing behavior on a transient auto-login failure: getUser() consumes SAML_USER_ID, so a one-off failure yields a single 403 and the next request re-runs a full IdP authentication (no lockout). Refs: #32365 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ut, imports (#32365) - SamlWebInterceptor loop-guard now clears WebKeys.REDIRECT_AFTER_LOGIN on the authenticated-403 path (mirrors SecurityUtils.sendPermissionDenied). Cleared inline in the interceptor so it also applies to /api/* destinations, where custom-error-page.jsp returns before its 403 branch would clear it. Kept inline (not via sendPermissionDenied) because the resolved User is not in scope here and this path must never fall to the 401 branch, which would re-set the attribute and re-enter the loop. [🟠 High] - DotSamlResource.logoutGet/logoutPost: return after RedirectUtil.sendRedirectHTML so the method no longer falls through to the unconditional DoesNotExistException throw (which wrote a second response over the committed redirect). The new 403 sign-out link makes this path user-facing on every click, so the one-line fix is pulled in here. [🟡 Medium] - SpeedyAssetServlet: revert to the original sessionless sendError(SC_UNAUTHORIZED) on the auth-time SecurityException, avoiding a new HttpSession allocation + REDIRECT_AFTER_LOGIN write on this high-traffic static-asset endpoint. Asset authorization is still enforced downstream by BinaryExporterServlet via the /contentAsset forward. [🟡 Medium] - CMSUrlUtil: add `import com.dotcms.util.SecurityUtils;` and call the method unqualified, matching BinaryExporterServlet. [🟡 Medium] - Test: assert the loop-guard clears REDIRECT_AFTER_LOGIN on the 403 path. Refs: #32365 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the review — addressed all four items in 🟠 High — loop-guard skipped clearing 🟡 Medium — 🟡 Medium — 🟡 Medium — FQN-inline call in The two |
|
Tick the box to add this pull request to the merge queue (same as
|
Problem
On SAML-protected sites, a SAML user without a back-end role (front-end-only) who lands on a back-end URL (e.g.
/dotAdmin/) — or any authenticated front-end user requesting a resource they lack READ on — was bounced back to the IdP repeatedly. Because the IdP re-authenticates the already-signed-in user but that never grants the missing access, the result was an infinite redirect loop (ERR_TOO_MANY_REDIRECTS) with no default "Access Denied" landing. This is the counterpart to the file-asset status-code fix in #36541.What this PR does
Cleanly separates authentication (401 → start login) from authorization (403 → Access Denied, never re-authenticate), and gives those users a real destination:
SecurityUtils.sendPermissionDenied(user, uri, req, resp): authenticated (non-anonymous) → 403 and clearsREDIRECT_AFTER_LOGIN; anonymous/null → 401 + sets it. Adopted byBinaryExporterServlet,SpeedyAssetServlet, andCMSUrlUtil(which previously keyed only onuser == null).custom-error-page.jspwith a provider-agnostic sign-out link: SAML SLO (/api/v1/dotsaml/logout/{host}) when the host has SAML enabled (Entra ID, Okta, …), else/dotCMS/logout. Honors the existing per-site/cms403Pagevanity override.SamlWebInterceptor: if auto-login resolved a SAML user yet the request is still not logged in for its destination (isAutoLogin() && isNotLogged()), return a clean 403 instead of redirecting to the IdP again. This trips on the first futile cycle and is immune to the per-callback session churn caused by SameSite cookies.Anonymous (not-logged-in) users still get the legitimate 401 → login flow; back-end/admin users still get 403.
Fixes / Refs
Testing
SecurityUtilsTest(401-vs-403 resolver) — 19/19 green.REDIRECT_AFTER_LOGIN); anonymous → 401 → login; SAML front-end user hitting a back-end URL now gets a 403 Access Denied with a sign-out link instead of an infinite loop.Follow-up (tracked separately)
DotSamlResource.logoutGet/logoutPostmissingreturnafter redirect (spuriousDoesNotExistException).🤖 Generated with Claude Code
This PR fixes: #32365