From d9bed355e5873c99a23b4f95d9635b2de80046c5 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 31 Mar 2026 20:03:41 -0700 Subject: [PATCH 1/4] change url mapping wildcard validation config value and address edge case direct mapping --- .../main/groovy/grails/config/Settings.groovy | 2 +- .../urlmappings/embeddedVariables.adoc | 6 +-- .../src/en/guide/upgrading/upgrading71x.adoc | 6 +-- .../app4/grails-app/conf/application.yml | 6 +-- .../app5/grails-app/conf/application.yml | 6 +-- .../WildcardValidationDisabledSpec.groovy | 2 +- ...AbstractGrailsControllerUrlMappings.groovy | 27 ++++++---- .../mvc/WildcardActionValidationSpec.groovy | 54 +++++++++++++++++++ 8 files changed, 81 insertions(+), 28 deletions(-) diff --git a/grails-core/src/main/groovy/grails/config/Settings.groovy b/grails-core/src/main/groovy/grails/config/Settings.groovy index d29d890c869..004c66acede 100644 --- a/grails-core/src/main/groovy/grails/config/Settings.groovy +++ b/grails-core/src/main/groovy/grails/config/Settings.groovy @@ -171,7 +171,7 @@ interface Settings { * * @since 7.1 */ - String WEB_URL_MAPPING_VALIDATE_WILDCARDS = 'grails.web.url.mapping.validateWildcards' + String URL_MAPPING_VALIDATE_WILDCARDS = 'grails.urlmapping.validateWildcards' /** * Whether to cache links generated by the link generator diff --git a/grails-doc/src/en/guide/theWebLayer/urlmappings/embeddedVariables.adoc b/grails-doc/src/en/guide/theWebLayer/urlmappings/embeddedVariables.adoc index e258e455e26..b6d9a3e3435 100644 --- a/grails-doc/src/en/guide/theWebLayer/urlmappings/embeddedVariables.adoc +++ b/grails-doc/src/en/guide/theWebLayer/urlmappings/embeddedVariables.adoc @@ -102,10 +102,8 @@ To disable this behavior, set in `application.yml`: [source,yaml] ---- grails: - web: - url: - mapping: - validateWildcards: false + urlmapping: + validateWildcards: false ---- You can also resolve the controller name and action name to execute dynamically using a closure: diff --git a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc index 5ec292bdc41..a8511a90435 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc @@ -719,8 +719,6 @@ If this causes issues with existing URL mappings, you can disable it in `applica [source,yaml] ---- grails: - web: - url: - mapping: - validateWildcards: false + urlmapping: + validateWildcards: false ---- \ No newline at end of file diff --git a/grails-test-examples/app4/grails-app/conf/application.yml b/grails-test-examples/app4/grails-app/conf/application.yml index 04d31232e30..8f7727e02ed 100644 --- a/grails-test-examples/app4/grails-app/conf/application.yml +++ b/grails-test-examples/app4/grails-app/conf/application.yml @@ -18,10 +18,8 @@ grails: profile: web codegen: defaultPackage: app4 - web: - url: - mapping: - validateWildcards: true + urlmapping: + validateWildcards: true info: app: name: '@info.app.name@' diff --git a/grails-test-examples/app5/grails-app/conf/application.yml b/grails-test-examples/app5/grails-app/conf/application.yml index 3fc1a627b37..4dbb6bec2ab 100644 --- a/grails-test-examples/app5/grails-app/conf/application.yml +++ b/grails-test-examples/app5/grails-app/conf/application.yml @@ -23,10 +23,8 @@ grails: profile: web codegen: defaultPackage: app5 - web: - url: - mapping: - validateWildcards: false + urlmapping: + validateWildcards: false spring: groovy: template: diff --git a/grails-test-examples/app5/src/integration-test/groovy/app5/WildcardValidationDisabledSpec.groovy b/grails-test-examples/app5/src/integration-test/groovy/app5/WildcardValidationDisabledSpec.groovy index 32b7d1daa05..0454ddbdab4 100644 --- a/grails-test-examples/app5/src/integration-test/groovy/app5/WildcardValidationDisabledSpec.groovy +++ b/grails-test-examples/app5/src/integration-test/groovy/app5/WildcardValidationDisabledSpec.groovy @@ -25,7 +25,7 @@ import spock.lang.Specification @Integration class WildcardValidationDisabledSpec extends Specification implements HttpClientSupport { - def 'grails.web.url.mapping.validateWildcards false keeps the fallback mapping selected ahead of a valid wildcard controller match'() { + def 'grails.urlmapping.validateWildcards false keeps the fallback mapping selected ahead of a valid wildcard controller match'() { when: 'requesting a path that matches both the fallback mapping and a registered controller' def response = http('/wildcard-disabled/target') diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy index ef831f9654d..7be578f7966 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy @@ -60,7 +60,7 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { this.urlMappingsHolderDelegate = urlMappingsHolderDelegate this.urlConverter = urlConverter this.validateWildcardMappings = grailsApplication.config.getProperty( - Settings.WEB_URL_MAPPING_VALIDATE_WILDCARDS, Boolean, true) + Settings.URL_MAPPING_VALIDATE_WILDCARDS, Boolean, true) def controllerArtefacts = grailsApplication.getArtefacts(ControllerArtefactHandler.TYPE) for (GrailsClass gc in controllerArtefacts) { registerController((GrailsControllerClass) gc) @@ -212,7 +212,8 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { def webRequest = GrailsWebRequest.lookup() List wildcardActionMatches = [] List otherMatches = [] - boolean hasLiteralControllerMatch = false + boolean hasPureLiteralMatch = false + Set explicitControllers = new HashSet<>() for (UrlMappingInfo info : infos) { if (info.redirectInfo) { otherMatches.add(info) @@ -230,10 +231,12 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { wildcardActionMatches.add(wrapped) } else { otherMatches.add(wrapped) - if (!hasLiteralControllerMatch) { - // A literal match has no URL-captured parameters beyond standard routing params + explicitControllers.add(info.controllerName) + if (!hasPureLiteralMatch) { + // A pure literal match has no URL-captured parameters beyond standard + // routing params (e.g., "/users" but not "/users/$username") def params = info.parameters - hasLiteralControllerMatch = params == null || params.keySet().every { it in ROUTING_PARAMS } + hasPureLiteralMatch = params == null || params.keySet().every { it in ROUTING_PARAMS } } } } else if (!validateWildcardMappings || !info.hasWildcardCaptures()) { @@ -241,13 +244,17 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { } // else: wildcard-captured values didn't match a registered controller/action — skip } - // Wildcard-resolved matches take priority over parameterized catch-all matches - // (e.g., $controller=feed beats $communitySlug=feed), but NOT over literal path - // matches (e.g., /community or post /invites) which are always more specific - if (hasLiteralControllerMatch) { + if (hasPureLiteralMatch) { + // Pure literal path (e.g., /community) — demote all wildcard matches (otherMatches + wildcardActionMatches) as UrlMappingInfo[] } else { - (wildcardActionMatches + otherMatches) as UrlMappingInfo[] + // Parameterized path (e.g., /users/$username) — only demote wildcard + // matches that target the same controller as an explicit mapping + // (e.g., post /invites → create demotes /invites/$action? → index, + // but $controller=feed is promoted over $username=feed) + List promoted = wildcardActionMatches.findAll { !(it.controllerName in explicitControllers) } + List demoted = wildcardActionMatches.findAll { it.controllerName in explicitControllers } + (promoted + otherMatches + demoted) as UrlMappingInfo[] } } diff --git a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy index 3c848fc7dac..ada9f7ca438 100644 --- a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy +++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy @@ -179,6 +179,54 @@ class WildcardActionValidationSpec extends AbstractUrlMappingsSpec { } } + void 'prefers explicit method-specific mappings inside parameterized group prefix'() { + when: 'an explicit POST mapping and a wildcard optional action mapping are inside a group with a URL variable' + def mappingInfo = evaluateRequestFor(requestURI: '/users/john/invites', method: 'POST', { + group "/users/$username", { + post "/invites"(controller: 'invite', action: 'create') + "/invites/$action?"(controller: 'invite') + } + }, InviteController) + + then: 'the explicit POST mapping is selected despite the group-level URL variable' + with(mappingInfo) { + controllerName == 'invite' + actionName == 'create' + } + } + + void 'GET to parameterized group routes to wildcard action index, not POST-only create'() { + when: 'a GET request matches both a POST-only mapping and a wildcard optional action mapping inside a group' + def mappingInfo = evaluateRequestFor(requestURI: '/users/john/invites', method: 'GET', { + group "/users/$username", { + post "/invites"(controller: 'invite', action: 'create') + "/invites/$action?"(controller: 'invite') + } + }, InviteController) + + then: 'the wildcard optional action mapping is selected (index), not the POST-only create' + with(mappingInfo) { + controllerName == 'invite' + actionName != 'create' + } + } + + void 'wildcard controller match beats parameterized catch-all for different controllers'() { + when: 'a wildcard $controller match (feed) and a $username catch-all (topic.home) both match' + def mappingInfo = evaluateRequestFor('/users/feed', { + "/users"(controller: 'topic', action: 'home') + group "/users", { + "/$controller/$action?"() + } + "/users/$username"(controller: 'topic', action: 'home') + }, TopicController, FeedController) + + then: 'the wildcard controller match wins because it resolved a real controller' + with(mappingInfo) { + controllerName == 'feed' + } + } + private UrlMappingsHolder createUrlMappingsHolder(boolean validateWildcardMappings = true, Closure mappings, Class... controllerClasses) { def grailsApplication = new DefaultGrailsApplication(controllerClasses).tap { initialise() @@ -219,6 +267,12 @@ class CommunityController { def index() {} } +@Controller +class FeedController { + + def index() {} +} + @Controller class TopicController { From 9239a3acc2b3424654dc826747094639be3da892 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 2 Apr 2026 23:29:19 -0700 Subject: [PATCH 2/4] sort based on specificity --- ...AbstractGrailsControllerUrlMappings.groovy | 43 ++++--------- .../mvc/WildcardActionValidationSpec.groovy | 62 ++++++++++++++++++- 2 files changed, 73 insertions(+), 32 deletions(-) diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy index 7be578f7966..653965611f6 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy @@ -210,13 +210,10 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { protected UrlMappingInfo[] collectControllerMappings(UrlMappingInfo[] infos) { def webRequest = GrailsWebRequest.lookup() - List wildcardActionMatches = [] - List otherMatches = [] - boolean hasPureLiteralMatch = false - Set explicitControllers = new HashSet<>() + List matches = [] for (UrlMappingInfo info : infos) { if (info.redirectInfo) { - otherMatches.add(info) + matches.add(info) continue } if (webRequest != null) { @@ -226,36 +223,20 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { ControllerKey controllerKey = new ControllerKey(info.namespace, info.controllerName, info.actionName, info.pluginName) GrailsControllerClass controllerClass = info ? mappingsToGrailsControllerMap.get(controllerKey) : null if (controllerClass) { - def wrapped = new GrailsControllerUrlMappingInfo(controllerClass, info) - if (validateWildcardMappings && info.hasWildcardCaptures()) { - wildcardActionMatches.add(wrapped) - } else { - otherMatches.add(wrapped) - explicitControllers.add(info.controllerName) - if (!hasPureLiteralMatch) { - // A pure literal match has no URL-captured parameters beyond standard - // routing params (e.g., "/users" but not "/users/$username") - def params = info.parameters - hasPureLiteralMatch = params == null || params.keySet().every { it in ROUTING_PARAMS } - } - } + matches.add(new GrailsControllerUrlMappingInfo(controllerClass, info)) } else if (!validateWildcardMappings || !info.hasWildcardCaptures()) { - otherMatches.add(info) + matches.add(info) } // else: wildcard-captured values didn't match a registered controller/action — skip } - if (hasPureLiteralMatch) { - // Pure literal path (e.g., /community) — demote all wildcard matches - (otherMatches + wildcardActionMatches) as UrlMappingInfo[] - } else { - // Parameterized path (e.g., /users/$username) — only demote wildcard - // matches that target the same controller as an explicit mapping - // (e.g., post /invites → create demotes /invites/$action? → index, - // but $controller=feed is promoted over $username=feed) - List promoted = wildcardActionMatches.findAll { !(it.controllerName in explicitControllers) } - List demoted = wildcardActionMatches.findAll { it.controllerName in explicitControllers } - (promoted + otherMatches + demoted) as UrlMappingInfo[] - } + // Sort by URL pattern specificity: fewer non-routing URL captures = more specific + matches.sort(true) { a, b -> nonRoutingParamCount(a) <=> nonRoutingParamCount(b) } + matches as UrlMappingInfo[] + } + + private static int nonRoutingParamCount(UrlMappingInfo info) { + def params = info.parameters + params == null ? 0 : (int) params.keySet().count { !(it in ROUTING_PARAMS) } } protected UrlMappingInfo collectControllerMapping(UrlMappingInfo info) { diff --git a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy index ada9f7ca438..5451d0be20a 100644 --- a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy +++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy @@ -23,6 +23,7 @@ import org.springframework.mock.web.MockHttpServletRequest import grails.core.DefaultGrailsApplication import grails.util.GrailsWebMockUtil import grails.web.Controller +import grails.web.HyphenatedUrlConverter import grails.web.mapping.AbstractUrlMappingsSpec import grails.web.mapping.UrlMappingInfo import grails.web.mapping.UrlMappingsHolder @@ -211,6 +212,52 @@ class WildcardActionValidationSpec extends AbstractUrlMappingsSpec { } } + void 'literal group path beats parameterized catch-all even with same controller'() { + when: 'a group with a literal sub-path and a $username catch-all both match, mapping to the same controller' + def mappingInfo = evaluateRequestFor('/users/gallery', { + group "/users", { + group "/gallery", controller: 'topic', { + "/$action?"() + } + } + "/users/$username"(controller: 'topic', action: 'home') + }, TopicController) + + then: 'the literal group path wins because it has a more specific URL pattern' + with(mappingInfo) { + controllerName == 'topic' + actionName != 'home' + } + } + + void 'wildcard action match beats explicit memberId match for hyphenated action URL'() { + given: + def urlConverter = new HyphenatedUrlConverter() + def mappingsHolder = createUrlMappingsHolder(urlConverter, { + group "/users/$username", { + group "/members", controller: 'member', { + "/$action?"() + "/$memberId"(action: 'remove') + } + } + }, MemberController) + + when: 'a hyphenated action URL matches both $action? and $memberId(remove) in a parameterized group' + def webRequest = GrailsWebMockUtil.bindMockWebRequest() + def request = (webRequest.request as MockHttpServletRequest).tap { + requestURI = '/users/john/members/opt-in-prompt' + method = 'GET' + } + def mappingInfo = new UrlMappingsHandlerMapping(mappingsHolder).getHandler(request)?.handler as UrlMappingInfo + + then: 'the $action? mapping wins because it has a more specific URL pattern' + mappingInfo != null + with(mappingInfo) { + controllerName == 'member' + actionName == 'opt-in-prompt' + } + } + void 'wildcard controller match beats parameterized catch-all for different controllers'() { when: 'a wildcard $controller match (feed) and a $username catch-all (topic.home) both match' def mappingInfo = evaluateRequestFor('/users/feed', { @@ -228,10 +275,14 @@ class WildcardActionValidationSpec extends AbstractUrlMappingsSpec { } private UrlMappingsHolder createUrlMappingsHolder(boolean validateWildcardMappings = true, Closure mappings, Class... controllerClasses) { + createUrlMappingsHolder(null, validateWildcardMappings, mappings, controllerClasses) + } + + private UrlMappingsHolder createUrlMappingsHolder(grails.web.UrlConverter urlConverter, boolean validateWildcardMappings = true, Closure mappings, Class... controllerClasses) { def grailsApplication = new DefaultGrailsApplication(controllerClasses).tap { initialise() } - new GrailsControllerUrlMappings(grailsApplication, getUrlMappingsHolder(mappings)).tap { + new GrailsControllerUrlMappings(grailsApplication, getUrlMappingsHolder(mappings), urlConverter).tap { it.validateWildcardMappings = validateWildcardMappings } } @@ -267,6 +318,15 @@ class CommunityController { def index() {} } +@Controller +class MemberController { + + def index() {} + def optInPrompt() {} + def optIn() {} + def remove() {} +} + @Controller class FeedController { From e4a56920a1cc95d4c2fad8563109934dcd43051a Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 3 Apr 2026 11:29:13 -0700 Subject: [PATCH 3/4] handle multi-capture edge case --- .../mvc/AbstractGrailsControllerUrlMappings.groovy | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy index 653965611f6..6ce8623757d 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy @@ -229,8 +229,14 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { } // else: wildcard-captured values didn't match a registered controller/action — skip } - // Sort by URL pattern specificity: fewer non-routing URL captures = more specific - matches.sort(true) { a, b -> nonRoutingParamCount(a) <=> nonRoutingParamCount(b) } + // Sort by specificity only when wildcard status differs between matches. + // When both have the same hasWildcardCaptures() status, the URL matcher's + // original order is preserved (stable sort) — critical for patterns like + // /$sku=v$variant vs /$sku where more captures doesn't mean less specific. + matches.sort(true) { a, b -> + if (a.hasWildcardCaptures() == b.hasWildcardCaptures()) return 0 + nonRoutingParamCount(a) <=> nonRoutingParamCount(b) + } matches as UrlMappingInfo[] } From 7a7617f3358ae33b34f1e8eaad64f7de31a7f2b4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sat, 4 Apr 2026 09:04:17 -0700 Subject: [PATCH 4/4] enforce previous behavior when validateWildcardMappings is disabled --- ...AbstractGrailsControllerUrlMappings.groovy | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy index 6ce8623757d..8bcdf16a8b4 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy @@ -229,13 +229,19 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { } // else: wildcard-captured values didn't match a registered controller/action — skip } - // Sort by specificity only when wildcard status differs between matches. - // When both have the same hasWildcardCaptures() status, the URL matcher's - // original order is preserved (stable sort) — critical for patterns like - // /$sku=v$variant vs /$sku where more captures doesn't mean less specific. - matches.sort(true) { a, b -> - if (a.hasWildcardCaptures() == b.hasWildcardCaptures()) return 0 - nonRoutingParamCount(a) <=> nonRoutingParamCount(b) + // When wildcard validation is enabled, promote validated wildcard matches + // (e.g., $action? resolving to a real action) only when they have strictly fewer + // non-routing URL captures — meaning they matched a more specific URL pattern. + // Same wildcard status: preserve URL matcher's original order (stable sort). + // When validation is disabled, preserve original URL matcher order entirely. + if (validateWildcardMappings) { + matches.sort(true) { a, b -> + if (a.hasWildcardCaptures() == b.hasWildcardCaptures()) return 0 + int diff = nonRoutingParamCount(a) - nonRoutingParamCount(b) + if (a.hasWildcardCaptures() && diff < 0) return -1 + if (b.hasWildcardCaptures() && diff < 0) return 1 + 0 // preserve original order + } } matches as UrlMappingInfo[] }