Skip to content

Fixed issue where GrailsExceptionResolver masks original exception wi… - #16396

Open
ruthst00 wants to merge 2 commits into
apache:8.0.xfrom
ruthst00:fix/16129-grailswebrequest-cast
Open

ruthst00 wants to merge 2 commits into
apache:8.0.xfrom
ruthst00:fix/16129-grailswebrequest-cast

Conversation

@ruthst00

Copy link
Copy Markdown

…th GrailsWebRequest cast failure.

Description

Root Cause

Two unconditional casts to GrailsWebRequest in the URL mapping layer would throw ClassCastException when Spring's RequestContextHolder held a plain ServletRequestAttributes (not a GrailsWebRequest). This happens during exception resolution when the Grails filter hasn't had a chance to upgrade the request attributes. The ClassCastException then replaced the original application exception in GrailsExceptionResolver, masking the real error.

Changes Made

grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java

  • In evaluateNameForValue(Object value): replaced the unconditional (GrailsWebRequest) RequestContextHolder.getRequestAttributes() cast with a safe instanceof check, passing null to the overload when the attributes are not a GrailsWebRequest.
  • In evaluateNameForValue(Object value, GrailsWebRequest webRequest): added null-guards for the Closure and Map branches so they return null gracefully when webRequest is null (rather than NPE-ing).

grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java

  • In getActionName(): replaced the unconditional (GrailsWebRequest) cast with the same safe instanceof pattern.

New test: grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy

  • 5 feature methods covering: closure-action mapping with plain ServletRequestAttributes bound, closure-action mapping with no attributes bound, and static string names always resolving correctly regardless of RequestContextHolder state.

Updated test: grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy

  • Added resolveViewOrForward does not mask the original exception when a plain ServletRequestAttributes is bound — directly reproduces the issue scenario and verifies the original exception is not masked.

Fixes #16129

Generated with Claude Sonnet 4.6 via Cline API Provider inside IntelliJ 2025.3.3

Contributor Checklist

Please review the following checklist before submitting your pull request. Pull requests that do not meet these requirements may be closed without review.

Issue and Scope

  • This PR is linked to an existing issue that has been acknowledged or approved by the project team. If no approved issue exists, please give background on why this change is necessary. Tickets are preferred for release change log history.
  • This PR addresses the complete scope of the linked issue. Partial implementations or unfinished work should not be submitted for review.
  • This PR contains a single, focused change. Unrelated changes should be submitted as separate pull requests.
  • This PR targets the correct branch for the type of change:
    • Patch release branches (e.g., 7.0.x): Bug fixes only. No new features or API changes.
    • Minor release branches (e.g., 7.1.x): New features are welcome, but breaking existing APIs must be avoided.
    • Major release branches (e.g., 8.0.x): Reserved for major changes. Breaking API changes are permitted.

Code Quality

  • I have added or updated tests that cover the changes introduced in this PR. All code contributions are expected to include appropriate test coverage.
  • I have verified that all existing tests pass by running ./gradlew build --rerun-tasks.
    • 3 Existing tests fail due to TTL race condition and database state pollution between parallel test runs
  • My code follows the project's code style guidelines. I have run ./gradlew codeStyle and resolved any violations. See Code Style for details.
  • This PR does not include mass reformatting, style-only changes, or large-scale refactoring unless it was explicitly approved in the linked issue. Unsolicited reformatting will not be accepted.
  • If generative AI tooling was used in preparing this contribution, a quality model was used to ensure contributions are consistent with the project's quality standards.

Licensing and Attribution

Documentation

  • If this PR introduces user-facing changes, I have included or updated the relevant documentation.
  • If this PR adds a new feature, I have updated the What's New section of the Grails Guide.
  • If this PR introduces breaking changes or changes that require user action during an upgrade, I have updated the Upgrade Notes for the corresponding version in the Grails Guide.
  • The PR description clearly explains what was changed and why.

First-time contributors: Please read our Contributing Guide before submitting.
Pull requests that appear to be auto-generated, incomplete, or unrelated to an approved issue may be
closed to help maintainers focus on reviewed and planned work. We appreciate your understanding.

…uest

A DispatcherServlet other than the Grails one - the one MockMvc runs, for
example - binds a plain ServletRequestAttributes over the GrailsWebRequest
that GrailsWebRequestFilter bound. Guarding the casts alone left
GrailsExceptionResolver forwarding to a controller error handler with a
null GrailsWebRequest, so the original exception was still replaced.

- URL mapping names, reverse mappings and the default URL creator find the
  GrailsWebRequest the filter stored on the request when the bound
  attributes are plain
- Without one, a name closure is not called and resolves to null, and a map
  of HTTP methods is keyed by the method of the bound request. With no
  attributes bound at all, a closure is still called without a delegate
- GrailsExceptionResolver forwards to a controller error handler only when
  the request has a GrailsWebRequest, and an error handler that cannot be
  resolved no longer replaces the exception being resolved: it is logged
  and the /error view renders the original exception
- The default URL creator no longer prefixes URLs with "null" when there is
  no context path

Fixes apache#16129

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @ruthst00 & thank you picking this up. I reviewed this PR with an LLM and have noted it's feedback below for others to review. I believe all of these issues are addressed in my latest push:

Guarding the cast is the right direction, but as it stands only a view-mapped status code is fixed. A controller-mapped one still loses the original exception, now to an NPE instead of a ClassCastException. Details are inline. I checked this in a local checkout by running resolveException with real URL mappings, and by reverting each production hunk against the new specs.

Beyond the inline comments:

  • The same unchecked (GrailsWebRequest) RequestContextHolder.getRequestAttributes() cast is still in RegexUrlMapping.createURLInternal and twice in DefaultUrlCreator. Those run when the error view renders a link under the same plain attributes. Since the issue asks that this path stop assuming a GrailsWebRequest, it makes sense to route them through the same lookup in this PR.

def resolver = new GrailsExceptionResolver() {

@Override
protected void forwardRequest(UrlMappingInfo forwarded, HttpServletRequest req,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overriding forwardRequest hides the part of this bug the PR doesn't fix yet (commenting here because GrailsExceptionResolver isn't in the diff). The real forwardRequest calls info.configure(WebUtils.retrieveGrailsWebRequest()), and retrieveGrailsWebRequest() returns null when the bound attributes aren't a GrailsWebRequest. populateParamsForMapping(null) then throws an NPE, which resolveViewOrForward wraps in a GrailsRuntimeException. So the original exception is still lost.

I ran resolveException against real mappings built by DefaultUrlMappingEvaluator, with a plain ServletRequestAttributes bound:

status mapping 8.0.x this PR
"500"(view: '/error') ClassCastException resolves (view set, status 500)
"500"(controller: 'errors', action: 'serverError') ClassCastException GrailsRuntimeException → NPE in populateParamsForMapping
"500"(controller: { 'errors' }, action: 'serverError') ClassCastException GrailsRuntimeException → UrlMappingException

Controller-mapped error handlers are common, so the resolver needs a fix as well. For the MockMvc case in the issue, GrailsWebRequestFilter has already stored the GrailsWebRequest on the request before the dispatcher rebinds plain attributes over it. So info.configure(GrailsWebRequest.lookup(request)) finds it; with just that change, the controller mapping forwards to /errors/serverError. When there's no GrailsWebRequest at all (the issue's direct resolveException reproduction), the forward can't be configured. Returning mv without forwarding would let the original exception reach the default error view. UrlMappingUtils.forwardRequestForUrlMappingInfo also dereferences GrailsWebRequest.lookup(request) without a null check, so that path needs the same guard.

RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse()))

and: 'a UrlMappingInfo whose getControllerName() triggers a closure-based name resolution'
def info = Mock(UrlMappingInfo)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes with both production changes reverted (I put AbstractUrlMappingInfo and DefaultUrlMappingInfo back to 8.0.x and it stays green). UrlMappingInfo and UrlMappingsHolder are mocks, so nothing reads RequestContextHolder, and getControllerName() is stubbed to return 'errors'. The closure-based name resolution described in the and: label never happens.

Could this build real mappings with DefaultUrlMappingEvaluator and DefaultUrlMappingsHolder, and go through resolveException (the entry point from the issue) without overriding forwardRequest? Cover both a view mapping and a controller mapping, and assert that the returned ModelAndView's exception entry wraps the original exception. That version fails today for the controller mapping (see the comment on forwardRequest) and will pin the fix once it's in.

GrailsWebRequest webRequest = (GrailsWebRequest) RequestContextHolder.getRequestAttributes();
org.springframework.web.context.request.RequestAttributes attrs =
RequestContextHolder.getRequestAttributes();
GrailsWebRequest webRequest = attrs instanceof GrailsWebRequest ? (GrailsWebRequest) attrs : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the frame from the issue's stack trace (getNamespace() → evaluateNameForValue(Object)), but no test fails if it's reverted. With only this hunk put back to the unconditional cast, all of AbstractUrlMappingInfoSafeCastSpec and GrailsExceptionResolverSpec still pass. The specs go through either getActionName(), which calls the two-arg overload directly, or a String controller name, which returns before the cast. A spec calling getNamespace() / getViewName() / getId() on a mapping with no namespace, while plain attributes are bound, would cover it.

Also, GrailsWebRequest.lookup() already does exactly this instanceof check. Use it, or the helper suggested on the closure branch below, here and in DefaultUrlMappingInfo.getActionName() instead of two inline copies with a fully-qualified RequestAttributes.


String name;
if (value instanceof Closure) {
if (webRequest == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning null here changes more than the cast:

  • When nothing is bound, the closure used to run anyway, with a null delegate. A closure that doesn't touch the request, such as action: { 'show' }, resolved to show on 8.0.x and resolves to null now.
  • A controller name that comes back null makes getControllerName() throw UrlMappingException. So a status mapping with a closure controller still masks the original exception; the ClassCastException just becomes a UrlMappingException (last row of the table on the forwardRequest comment).

In the MockMvc case from the issue the GrailsWebRequest still exists: GrailsWebRequestFilter stored it on the request before the dispatcher bound plain attributes over it. When the bound attributes are a ServletRequestAttributes, GrailsWebRequest.lookup(((ServletRequestAttributes) attrs).getRequest()) recovers it, and the closure keeps evaluating against the real request. null would then only be returned when no GrailsWebRequest exists anywhere. A private helper for that lookup would also replace the inline copies in both files.

name = result != null ? result.toString() : null;
}
else if (value instanceof Map) {
if (webRequest == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Selecting by HTTP method doesn't need a GrailsWebRequest: HiddenHttpMethod.effectiveMethod takes the HttpServletRequest, which a plain ServletRequestAttributes already has. As written, action: [GET: 'show', POST: 'save'] quietly resolves to null whenever the bound attributes aren't Grails'. Taking the request from the bound ServletRequestAttributes would keep this branch working. No spec covers the Map branch yet.

actionName == null
}

void 'getActionName does not throw ClassCastException when RequestContextHolder holds a plain ServletRequestAttributes'() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature is identical to the one above: same mapping, same bound attributes, same info.actionName call. The one above is named for evaluateNameForValue(Object) but never reaches it. Also in this spec:

  • The empty-holder feature below is named for ClassCastException, but on 8.0.x it failed with an NPE (casting a null holder is fine).
  • The static controller-name feature passes on 8.0.x, because a String name returns before the cast.

A data-driven feature would cover the one-arg path this spec is missing. Run it over the public getters (namespace, controllerName, actionName, viewName, id), crossed with the holder state (GrailsWebRequest, plain ServletRequestAttributes, nothing bound) and the value shape (String, closure, method Map).

* {@link ServletRequestAttributes} instead of a {@link org.grails.web.servlet.mvc.GrailsWebRequest}.
*
* <p>This scenario arises when {@link org.grails.web.errors.GrailsExceptionResolver} resolves an
* exception: Spring's {@code DispatcherServlet} may have bound a {@code ServletRequestAttributes}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't quite how the plain attributes get there. In a Grails app, GrailsDispatcherServlet.buildRequestAttributes replaces a plain ServletRequestAttributes with a GrailsWebRequest, so exception resolution under Grails' own dispatcher always sees one. The plain attributes come either from a non-Grails dispatcher rebinding over the GrailsWebRequest that GrailsWebRequestFilter set (MockMvc's TestDispatcherServlet, as in the issue), or from code calling the resolver directly. Could the Javadoc, and the root-cause section of the PR description, say that?

@jdaugherty

Copy link
Copy Markdown
Contributor

@matrei @codeconsole can one of you review this too please? I believe I've fixed all of the issues I found with it.

@codecov

codecov Bot commented Sep 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.71429% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.6932%. Comparing base (a441334) to head (912be07).
⚠️ Report is 24 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...org/grails/web/errors/GrailsExceptionResolver.java 68.4210% 4 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16396        +/-   ##
==================================================
+ Coverage     57.4998%   57.6932%   +0.1934%     
- Complexity      22590      22749       +159     
==================================================
  Files            2129       2133         +4     
  Lines          103863     104248       +385     
  Branches        18651      18701        +50     
==================================================
+ Hits            59721      60144       +423     
+ Misses          35871      35808        -63     
- Partials         8271       8296        +25     
Files with missing lines Coverage Δ
...org/grails/web/mapping/AbstractUrlMappingInfo.java 86.2500% <100.0000%> (-1.4212%) ⬇️
...oovy/org/grails/web/mapping/DefaultUrlCreator.java 57.9439% <100.0000%> (+2.2835%) ⬆️
.../org/grails/web/mapping/DefaultUrlMappingInfo.java 54.9020% <100.0000%> (+9.2709%) ⬆️
...groovy/org/grails/web/mapping/RegexUrlMapping.java 69.2748% <100.0000%> (+1.5267%) ⬆️
...groovy/org/grails/web/mapping/UrlMappingUtils.java 61.2903% <100.0000%> (+14.9816%) ⬆️
...org/grails/web/errors/GrailsExceptionResolver.java 80.5556% <68.4210%> (+6.6425%) ⬆️

... and 16 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@testlens-app

testlens-app Bot commented Sep 25, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 912be07
▶️ Tests: 82660 executed
⚪️ Checks: 88/88 completed


Learn more about TestLens at testlens.app/docs.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

GrailsExceptionResolver masks original exception with GrailsWebRequest cast failure

2 participants