From bfdbbe88fa63c33b92e3a0ed7e0509ef99b500ac Mon Sep 17 00:00:00 2001 From: "g.sartori" Date: Mon, 20 Jul 2026 13:08:24 +0200 Subject: [PATCH 1/9] PR for: https://github.com/apache/grails-core/issues/15644 --- .../ControllersAutoConfiguration.java | 43 ++++++++++--------- .../ControllersAutoConfigurationSpec.groovy | 24 +++++++++++ .../main/groovy/grails/config/Settings.groovy | 20 --------- .../controllers/uploadingFiles.adoc | 19 ++++---- .../spring-configuration-metadata.json | 24 ----------- 5 files changed, 58 insertions(+), 72 deletions(-) diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java index 1d7527b2d78..e51afd8990f 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java @@ -26,18 +26,26 @@ import jakarta.servlet.Filter; import jakarta.servlet.MultipartConfigElement; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.servlet.autoconfigure.HttpEncodingAutoConfiguration; import org.springframework.boot.servlet.filter.OrderedCharacterEncodingFilter; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletRegistrationBean; +import org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletRegistrationBean; import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; import org.springframework.context.ApplicationContext; +import org.springframework.context.EnvironmentAware; import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; import org.springframework.util.ClassUtils; import org.springframework.web.filter.CharacterEncodingFilter; import org.springframework.web.servlet.DispatcherServlet; @@ -58,7 +66,15 @@ after = {GrailsDomainClassAutoConfiguration.class} ) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) -public class ControllersAutoConfiguration { +public class ControllersAutoConfiguration implements EnvironmentAware { + + private static final String LEGACY_MULTIPART_CONFIGURATION = "grails.controllers.upload"; + + static final String LEGACY_MULTIPART_CONFIGURATION_ERROR = + "Configuration properties under 'grails.controllers.upload' are no longer supported. " + + "Use Spring Boot's 'spring.servlet.multipart' configuration instead. For example, set " + + "'spring.servlet.multipart.max-file-size=200MB' and " + + "'spring.servlet.multipart.max-request-size=200MB'."; @Value("${" + Settings.FILTER_ENCODING + ":utf-8}") private String filtersEncoding; @@ -75,18 +91,6 @@ public class ControllersAutoConfiguration { @Value("${" + Settings.RESOURCES_PATTERN + ":" + Settings.DEFAULT_RESOURCE_PATTERN + "}") private String resourcesPattern; - @Value("${" + Settings.CONTROLLERS_UPLOAD_LOCATION + ":#{null}}") - private String uploadTmpDir; - - @Value("${" + Settings.CONTROLLERS_UPLOAD_MAX_FILE_SIZE + ":128000}") - private long maxFileSize; - - @Value("${" + Settings.CONTROLLERS_UPLOAD_MAX_REQUEST_SIZE + ":128000}") - private long maxRequestSize; - - @Value("${" + Settings.CONTROLLERS_UPLOAD_FILE_SIZE_THRESHOLD + ":0}") - private int fileSizeThreshold; - @Value("${" + Settings.WEB_SERVLET_PATH + ":#{null}}") String grailsServletPath; @@ -150,12 +154,11 @@ public FilterRegistrationBean grailsWebRequestFilter(Gra return registrationBean; } - @Bean - public MultipartConfigElement multipartConfigElement() { - if (uploadTmpDir == null) { - uploadTmpDir = System.getProperty("java.io.tmpdir"); + @Override + public void setEnvironment(Environment environment) { + if (Binder.get(environment).bind(LEGACY_MULTIPART_CONFIGURATION, Bindable.mapOf(String.class, Object.class)).isBound()) { + throw new IllegalStateException(LEGACY_MULTIPART_CONFIGURATION_ERROR); } - return new MultipartConfigElement(uploadTmpDir, maxFileSize, maxRequestSize, fileSizeThreshold); } @Bean @@ -164,7 +167,7 @@ public DispatcherServlet dispatcherServlet() { } @Bean - public DispatcherServletRegistrationBean dispatcherServletRegistration(GrailsApplication application, DispatcherServlet dispatcherServlet, MultipartConfigElement multipartConfigElement) { + public DispatcherServletRegistrationBean dispatcherServletRegistration(GrailsApplication application, DispatcherServlet dispatcherServlet, ObjectProvider multipartConfigElement) { if (grailsServletPath == null) { boolean isTomcat = ClassUtils.isPresent("org.apache.catalina.startup.Tomcat", application.getClassLoader()); grailsServletPath = isTomcat ? Settings.DEFAULT_TOMCAT_SERVLET_PATH : Settings.DEFAULT_WEB_SERVLET_PATH; @@ -172,7 +175,7 @@ public DispatcherServletRegistrationBean dispatcherServletRegistration(GrailsApp DispatcherServletRegistrationBean dispatcherServletRegistration = new DispatcherServletRegistrationBean(dispatcherServlet, grailsServletPath); dispatcherServletRegistration.setLoadOnStartup(2); dispatcherServletRegistration.setAsyncSupported(true); - dispatcherServletRegistration.setMultipartConfig(multipartConfigElement); + multipartConfigElement.ifAvailable(dispatcherServletRegistration::setMultipartConfig); return dispatcherServletRegistration; } diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy index 1d2c17b61f1..b5ef227a8d8 100644 --- a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy @@ -19,6 +19,8 @@ package org.grails.plugins.web.controllers +import org.springframework.beans.factory.BeanCreationException +import org.springframework.core.env.MapPropertySource import java.util.function.Supplier import grails.core.DefaultGrailsApplication @@ -35,6 +37,7 @@ import org.springframework.context.ConfigurableApplicationContext import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.mock.web.MockServletContext +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext import org.springframework.web.context.WebApplicationContext import org.springframework.web.context.support.StaticWebApplicationContext import org.springframework.web.filter.RequestContextFilter @@ -54,6 +57,27 @@ class ControllersAutoConfigurationSpec extends Specification { def autoConfiguration = new ControllersAutoConfiguration() + def "legacy multipart configuration fails startup with migration instructions"() { + given: + def applicationContext = new AnnotationConfigWebApplicationContext() + applicationContext.servletContext = new MockServletContext() + applicationContext.environment.propertySources.addFirst(new MapPropertySource('test', [ + 'grails.controllers.upload.maxFileSize': 20000000, + ])) + applicationContext.register(ControllersAutoConfiguration) + + when: + applicationContext.refresh() + + then: + BeanCreationException exception = thrown() + exception.rootCause instanceof IllegalStateException + exception.rootCause.message == ControllersAutoConfiguration.LEGACY_MULTIPART_CONFIGURATION_ERROR + + cleanup: + applicationContext.close() + } + void 'grailsWebRequest filter is a RequestContextFilter so Boot WebMvcAutoConfiguration backs off its own RequestContextFilter'() { when: 'the Grails request-binding filter bean is created' GrailsWebRequestFilter filter = autoConfiguration.grailsWebRequest(applicationContext) diff --git a/grails-core/src/main/groovy/grails/config/Settings.groovy b/grails-core/src/main/groovy/grails/config/Settings.groovy index 6acf468f8b8..ff65c2a40bc 100644 --- a/grails-core/src/main/groovy/grails/config/Settings.groovy +++ b/grails-core/src/main/groovy/grails/config/Settings.groovy @@ -209,26 +209,6 @@ interface Settings { */ String CONTROLLERS_DEFAULT_SCOPE = 'grails.controllers.defaultScope' - /** - * The upload directory for controllers, defaults to java.tmp.dir - */ - String CONTROLLERS_UPLOAD_LOCATION = 'grails.controllers.upload.location' - - /** - * The maximum file size - */ - String CONTROLLERS_UPLOAD_MAX_FILE_SIZE = 'grails.controllers.upload.maxFileSize' - - /** - * The maximum request size - */ - String CONTROLLERS_UPLOAD_MAX_REQUEST_SIZE = 'grails.controllers.upload.maxRequestSize' - - /** - * The file size threshold - */ - String CONTROLLERS_UPLOAD_FILE_SIZE_THRESHOLD = 'grails.controllers.upload.fileSizeThreshold' - /** * The encoding to use for filters, default to UTF-8 */ diff --git a/grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc b/grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc index 606232098e7..2d0bf980690 100644 --- a/grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc +++ b/grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc @@ -91,7 +91,7 @@ class Image { ==== Increase Upload Max File Size -Grails default size for file uploads is 128000 (~128KB). When this limit is exceeded you'll see the following exception: +Spring Boot's default size for file uploads is 1MB and its default maximum multipart request size is 10MB. When a limit is exceeded you'll see the following exception: [source,java] ---- @@ -103,16 +103,19 @@ You can configure the limit in your `application.yml` as follows: [source,yml] .grails-app/conf/application.yml ---- -grails: - controllers: - upload: - maxFileSize: 2000000 - maxRequestSize: 2000000 +spring: + servlet: + multipart: + max-file-size: 200MB + max-request-size: 200MB ---- -`maxFileSize` = The maximum size allowed for uploaded files. +`max-file-size` = The maximum size allowed for an uploaded file. -`maxRequestSize` = The maximum size allowed for multipart/form-data requests. +`max-request-size` = The maximum size allowed for a multipart/form-data request. + +The `spring.servlet.multipart` namespace also supports Spring Boot's `location`, `file-size-threshold`, and `enabled` properties. +The former `grails.controllers.upload` configuration is no longer supported. Applications using it fail at startup with a message explaining how to migrate to `spring.servlet.multipart`. You should keep in mind https://www.owasp.org/index.php/Unrestricted_File_Upload[OWASP recommendations - Unrestricted File Upload] diff --git a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json index 831f5866b67..ced384e6567 100644 --- a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json @@ -52,30 +52,6 @@ "description": "The default scope for controllers (singleton, prototype, session).", "defaultValue": "singleton" }, - { - "name": "grails.controllers.upload.location", - "type": "java.lang.String", - "description": "The directory for temporary file uploads.", - "defaultValue": "System.getProperty('java.io.tmpdir')" - }, - { - "name": "grails.controllers.upload.maxFileSize", - "type": "java.lang.Integer", - "description": "Maximum file size for uploads (in bytes).", - "defaultValue": 1048576 - }, - { - "name": "grails.controllers.upload.maxRequestSize", - "type": "java.lang.Integer", - "description": "Maximum request size for multipart uploads (in bytes).", - "defaultValue": 10485760 - }, - { - "name": "grails.controllers.upload.fileSizeThreshold", - "type": "java.lang.Integer", - "description": "File size threshold (in bytes) above which uploads are written to disk.", - "defaultValue": 0 - }, { "name": "grails.web.url.converter", "type": "java.lang.String", From d1ca11fc663192167367efae9e14874684d03a70 Mon Sep 17 00:00:00 2001 From: "g.sartori" Date: Mon, 20 Jul 2026 17:29:43 +0200 Subject: [PATCH 2/9] Merge fixes & cleanups --- THREAT_MODEL.md | 6 +++--- .../controllers/ControllersAutoConfiguration.java | 7 ++----- .../ControllersAutoConfigurationSpec.groovy | 6 +++--- threat-model.yaml | 14 +++++++------- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 1452a1b84e2..bb098481cf7 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -191,7 +191,7 @@ The framework exposes a small number of configuration knobs whose value affects | `grails.databinding.autoGrowCollectionLimit` | 256 *(documented: [`SimpleDataBinder.groovy`](./grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy))* | Caps automatic collection growth during data binding - hard limit on memory amplification from an attacker submitting deeply indexed parameters (`list[1000000]=x`). Raising removes the cap. | **§14 wave 2** - is the documented default the supported production posture, or is the operator expected to lower it? | | `grails.databinding.dateFormats` / `dateParsingLenient` | RFC-3339 + locale defaults; lenient parsing on | Affects how strict date binding is. Loose parsing has historically been a source of validation-bypass findings in other frameworks. | **§14 wave 2** | | `grails.views.default.codec` and codec defaults (`grails.views.gsp.codecs.expression`, `scriptlet`, `taglib`, `staticparts`) | `html` for expression / scriptlet contexts (XSS protection on by default) | Setting any of these to `none` **disables automatic output encoding** for that context - immediate `OUT-OF-MODEL: non-default-build` for XSS reports under non-default settings. *(documented: [xssPrevention.adoc](./grails-doc/src/en/guide/security/xssPrevention.adoc), [codecs.adoc](./grails-doc/src/en/guide/security/codecs.adoc))* | **§14 wave 1** - confirm the `html` default is the supported production posture. | -| `grails.controllers.upload.maxFileSize` / `maxRequestSize` | **128000 bytes (~125 KB) each**, set by `ControllersAutoConfiguration` (overrides Spring Boot's `MultipartProperties` defaults). *(documented: [`ControllersAutoConfiguration.java`](./grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java))* | Multipart upload size cap. Operators who raise these past their application's actual need expose themselves to DoS via large multipart bodies. | **§14 wave 2** | +| `spring.servlet.multipart.max-file-size` / `max-request-size` | **1 MB per file / 10 MB per request**, provided by Spring Boot's `MultipartProperties` defaults. *(documented: [uploadingFiles.adoc](./grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc))* | Multipart upload size cap. Operators who raise these past their application's actual need expose themselves to DoS via large multipart bodies. | **§14 wave 2** | | `grails.allowedMethods` (per-controller) | None (developer opt-in) | Restricts HTTP methods accepted by each action. Absence is **not** a finding; the model treats per-action method gating as a developer responsibility. *(inferred)* | **§14 wave 1** | | `grails.config.locations` (env var, system property, or config) | Empty | Adds external config file paths. **A non-empty value sourced from an untrusted location is a `BY-DESIGN: property-disclaimed` triage outcome** - see §9. | **§14 wave 1** - confirm this disposition. | | `GRAILS_ENV` / `grails.env` | `development` from CLI, `production` for assembled bootJars | Selects the active environment block in `application.yml` / `application.groovy`. Operators who deploy with `GRAILS_ENV=development` inherit the looser dev defaults (e.g., stack traces in responses). | **§14 wave 1** - is deploying with `development` a `non-default-build` posture? | @@ -212,7 +212,7 @@ The framework's public input boundary is the HTTP request. Per-parameter trust i | `Controller.params` | All values | **Yes** - direct request parameter map | Type coercion correctness; never concatenate into HQL/SQL/JPQL/Groovy strings; never use as redirect target without an allow-list. *(documented: [securingAgainstAttacks.adoc](./grails-doc/src/en/guide/security/securingAgainstAttacks.adoc) "XSS", "HTML/URL injection")* | | `Controller.request.headers` | All values | **Yes** - including `X-Forwarded-*`, `Host`, `User-Agent`, `Referer`, custom auth tokens | Treat presence as evidence of nothing; auth headers must be verified against the configured auth subsystem (Spring Security or equivalent). *(inferred)* | | `Controller.request.cookies` | All values | **Yes** | Treat as attacker-supplied; if used for auth, integrity-protect via Spring Security or signed cookies. *(inferred)* | -| `Controller.request.JSON` / `XML` | Full body | **Yes** | Parser inputs are bounded by `maxRequestSize`; nested-depth limits are the parser's responsibility (Jackson, JAXP). *(inferred)* | +| `Controller.request.JSON` / `XML` | Full body | **Yes** | Configure server request-size limits appropriate for the deployment; nested-depth limits are the parser's responsibility (Jackson, JAXP). *(inferred)* | | `bindData(target, source)` | `source` (any `Map` or request) | **Yes** for the source; **No** for the target type (developer-controlled) | Use `bindable`/`include`/`exclude` to whitelist fields. The framework will bind every settable property of `target` from matching keys in `source` unless told otherwise. *(documented: [GORM data binding guide](https://grails.apache.org/docs/latest/guide/single.html#dataBinding))* | | Command-object binding (auto-bound controller action parameter) | Field values | **Yes** | Annotate command-object fields with `bindable=false` for fields that must not be set from the request. *(inferred)* | | Domain-class binding (`new Book(params)`, `book.properties = params`) | Field values | **Yes** | **Mass-assignment risk.** Use command objects or explicit allow-lists rather than binding the request to a domain class. *(inferred)* | @@ -295,7 +295,7 @@ Each property is stated with its conditions, the symptom of a violation, a sever | P6 | **Data binding respects `bindable=false` and explicit `include`/`exclude` lists.** | [CWE-915](https://cwe.mitre.org/data/definitions/915.html) | Field is annotated or the binding call explicitly lists allowed/forbidden fields. | A field marked unbindable is set from request input. | **Security-critical (CVE-eligible)** | *(inferred)* | | P7 | **Compile-time AST transforms (`@Resource`, `@Validateable`, etc.) only act on developer-authored source.** | [CWE-94](https://cwe.mitre.org/data/definitions/94.html) | Build runs on developer-controlled source. | A transform fires on or is influenced by attacker-supplied input. | **Correctness** (security-critical only if reachable from a non-build attacker) | *(inferred)* | | P8 | **Configuration loading does not evaluate `application.groovy` from a path the framework itself chose at runtime - paths come from build-time classpath and operator-supplied environment/system properties.** | [CWE-94](https://cwe.mitre.org/data/definitions/94.html) | Operator has not pointed `grails.config.locations` at attacker-writable storage. | A user request causes evaluation of a Groovy file the operator did not authorize. | **Security-critical (CVE-eligible)** if violated. | *(inferred)* (§14 wave 1) | -| P9 | **`maxFileSize` / `maxRequestSize` / `autoGrowCollectionLimit` provide bounded data-binding memory.** | [CWE-770](https://cwe.mitre.org/data/definitions/770.html) | Operator does not raise the limits past application needs. | Memory growth proportional to attacker-controlled input regardless of limit. | **Resource bug** | *(inferred)* (§14 wave 2) | +| P9 | **Multipart upload limits and `autoGrowCollectionLimit` bound request-processing memory.** | [CWE-770](https://cwe.mitre.org/data/definitions/770.html) | Operator does not raise the limits past application needs. | Memory growth proportional to attacker-controlled input regardless of limit. | **Resource bug** | *(inferred)* (§14 wave 2) | ### Resource consumption line diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java index e51afd8990f..8bb61519748 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java @@ -31,13 +31,10 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.servlet.autoconfigure.HttpEncodingAutoConfiguration; -import org.springframework.boot.servlet.filter.OrderedCharacterEncodingFilter; -import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletRegistrationBean; -import org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration; -import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.servlet.autoconfigure.HttpEncodingAutoConfiguration; +import org.springframework.boot.servlet.filter.OrderedCharacterEncodingFilter; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletRegistrationBean; diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy index b5ef227a8d8..fb90f00b612 100644 --- a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy @@ -19,13 +19,12 @@ package org.grails.plugins.web.controllers -import org.springframework.beans.factory.BeanCreationException -import org.springframework.core.env.MapPropertySource import java.util.function.Supplier import grails.core.DefaultGrailsApplication import grails.core.GrailsApplication +import org.springframework.beans.factory.BeanCreationException import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.test.context.runner.WebApplicationContextRunner import org.springframework.boot.web.servlet.AbstractFilterRegistrationBean @@ -34,11 +33,12 @@ import org.springframework.boot.web.servlet.ServletContextInitializerBeans import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration import org.springframework.context.ApplicationContext import org.springframework.context.ConfigurableApplicationContext +import org.springframework.core.env.MapPropertySource import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.mock.web.MockServletContext -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext import org.springframework.web.context.WebApplicationContext +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext import org.springframework.web.context.support.StaticWebApplicationContext import org.springframework.web.filter.RequestContextFilter import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver diff --git a/threat-model.yaml b/threat-model.yaml index 0a2a84edb8d..8a3faa414f3 100644 --- a/threat-model.yaml +++ b/threat-model.yaml @@ -124,17 +124,17 @@ config_knobs: security_relevant: true maintainer_stance: unresolved section: "§5a" - - name: grails.controllers.upload.maxFileSize - default: 128000 + - name: spring.servlet.multipart.max-file-size + default: 1048576 default_units: bytes - default_source: grails-controllers/.../ControllersAutoConfiguration.java + default_source: Spring Boot MultipartProperties security_relevant: true maintainer_stance: unresolved section: "§5a" - - name: grails.controllers.upload.maxRequestSize - default: 128000 + - name: spring.servlet.multipart.max-request-size + default: 10485760 default_units: bytes - default_source: grails-controllers/.../ControllersAutoConfiguration.java + default_source: Spring Boot MultipartProperties security_relevant: true maintainer_stance: unresolved section: "§5a" @@ -298,7 +298,7 @@ properties_provided: provenance: inferred open_question: "§14 wave 1" - id: P9 - description: "maxFileSize, maxRequestSize, autoGrowCollectionLimit provide bounded data-binding memory." + description: "Multipart upload limits and autoGrowCollectionLimit bound request-processing memory." cwe: CWE-770 conditions: "Operator does not raise the limits past application needs." violation_symptom: "Memory growth proportional to input regardless of configured limit." From a77c8f2f7bf3dd0abde8cca34f4e3eab9120de13 Mon Sep 17 00:00:00 2001 From: "g.sartori" Date: Mon, 20 Jul 2026 20:26:04 +0200 Subject: [PATCH 3/9] Fix proposal for https://github.com/apache/grails-core/issues/15736 --- .../hibernate/HibernateToManyProperty.java | 9 +-- .../HibernateToManyPropertySpec.groovy | 69 +++++++++++++++++++ .../ormdsl/customNamingStrategy.adoc | 2 + 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java index e94fdac4395..57ab76084a3 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java @@ -212,10 +212,9 @@ default String resolveJoinTableForeignKeyColumnName(PersistentEntityNamingStrate return ofNullable(getHibernateMappedForm()) .map(PropertyConfig::getJoinTableColumnConfig) .map(ColumnConfig::getName) - .orElseGet(() -> namingStrategy.resolveColumnName(getHibernateAssociatedEntity() + .orElseGet(() -> getHibernateAssociatedEntity() .getHibernateRootEntity() - .getJavaClass() - .getSimpleName()) + + .getTableName(namingStrategy) + GrailsDomainBinder.FOREIGN_KEY_SUFFIX); } @@ -227,7 +226,9 @@ default String joinTableColumName(PersistentEntityNamingStrategy namingStrategy) if (present) { columnName = joinColumnMappingOptional.get().getName(); } else { - var clazz = namingStrategy.resolveColumnName(referencedType.getName()); + var clazz = isBasic() ? + namingStrategy.resolveColumnName(referencedType.getName()) : + getHibernateAssociatedEntity().getHibernateRootEntity().getTableName(namingStrategy); var prop = namingStrategy.resolveTableName(getName()); columnName = referencedType.isEnum() ? clazz : diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy index d530896428b..3204a93651b 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy @@ -21,6 +21,9 @@ package org.grails.orm.hibernate.cfg.domainbinding.hibernate import grails.gorm.annotation.Entity import grails.gorm.tests.HibernateGormDatastoreSpec import org.hibernate.MappingException +import org.hibernate.boot.model.naming.Identifier +import org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl +import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment import org.grails.datastore.mapping.model.PersistentProperty import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.model.PropertyMapping @@ -28,6 +31,8 @@ import org.grails.datastore.mapping.model.ClassMapping import org.grails.datastore.mapping.reflect.EntityReflector import org.grails.orm.hibernate.cfg.PropertyConfig import org.grails.orm.hibernate.cfg.Mapping +import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy +import org.grails.orm.hibernate.cfg.domainbinding.util.NamingStrategyWrapper class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec { @@ -63,6 +68,27 @@ class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec { columnName == "custom_book_fk" } + void "resolveJoinTableForeignKeyColumnName removes a domain prefix through a physical naming strategy"() { + given: + def property = createTestHibernateToManyProperty(HTMPAuthor, "books") + def namingStrategy = new NamingStrategyWrapper( + new HTMPPrefixRemovingPhysicalNamingStrategy(), getGrailsDomainBinder().jdbcEnvironment) + hibernateFirstPass() + + expect: + property.resolveJoinTableForeignKeyColumnName(namingStrategy) == "book_id" + } + + void "resolveJoinTableForeignKeyColumnName uses the associated entity explicit table mapping"() { + given: + def property = createTestHibernateToManyProperty(HTMPMappedTableAuthor, "books") + def namingStrategy = getGrailsDomainBinder().namingStrategy + hibernateFirstPass() + + expect: + property.resolveJoinTableForeignKeyColumnName(namingStrategy) == "htmp_book_id" + } + void "isAssociationColumnNullable returns false for ManyToMany"() { given: "Register only entities for this specific test" createPersistentEntity(HTMPCourse) // Course is needed because Student refers to it @@ -351,6 +377,22 @@ class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec { property.joinTableColumName(namingStrategy) != null } + void "joinTableColumName applies table naming to an associated entity"() { + given: + def property = createTestHibernateToManyProperty(HTMPAuthor, "books") + def namingStrategy = Mock(PersistentEntityNamingStrategy) + hibernateFirstPass() + + when: + String columnName = property.joinTableColumName(namingStrategy) + + then: + 1 * namingStrategy.resolveTableName(_ as GrailsHibernatePersistentEntity) >> "book" + 1 * namingStrategy.resolveTableName("books") >> "books" + 0 * namingStrategy.resolveColumnName(_) + columnName == "books_book" + } + void "joinTableColumName returns derived column name for enum collection"() { given: def property = createTestHibernateToManyProperty(HTMPEntityWithEnum, "statuses") @@ -581,6 +623,33 @@ class HTMPBook { String title } +@Entity +class Book { + Long id + String title + + static mapping = { + table 'htmp_book' + } +} + +@Entity +class HTMPMappedTableAuthor { + Long id + String name + static hasMany = [books: Book] +} + +class HTMPPrefixRemovingPhysicalNamingStrategy extends PhysicalNamingStrategyStandardImpl { + + @Override + Identifier toPhysicalTableName(Identifier logicalName, JdbcEnvironment jdbcEnvironment) { + logicalName.text == HTMPBook.simpleName ? + Identifier.toIdentifier('book') : + super.toPhysicalTableName(logicalName, jdbcEnvironment) + } +} + @Entity class HTMPAuthor { Long id diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc index d7162af30fd..6dc391b3c55 100644 --- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc +++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc @@ -68,3 +68,5 @@ class UpperCaseNamingStrategy implements PhysicalNamingStrategy { ---- TIP: Individual column or table names set explicitly in the `mapping` block always take precedence over what the naming strategy would produce. + +The default foreign-key column names in a `hasMany` join table are derived from the physical table names of the associated domain classes. Consequently, a custom strategy that changes a domain table name also changes the corresponding join-table foreign-key column prefix. For example, if the strategy maps `TBook` to the table `book`, the default foreign-key column is `book_id`, not `tbook_id`. Applications upgrading from an earlier GORM version should account for this schema change or configure the join-table columns explicitly in the `mapping` block. From aa02d74cf6a0b0412da2c1eb3cb33b1af341b053 Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Mon, 20 Jul 2026 21:54:27 -0500 Subject: [PATCH 4/9] Consolidate join-table table-name resolution and fix property-prefix naming getHibernateAssociatedEntity().getHibernateRootEntity().getTableName(namingStrategy) was duplicated between resolveJoinTableForeignKeyColumnName() and joinTableColumName(). Extract it to HibernateAssociation#resolveAssociatedEntityTableName so both to-one and to-many association properties share one implementation. joinTableColumName() also resolved the collection property-name prefix via resolveTableName(getName()) even though the result is used as a column, not a table, on the join table. Under the default snake-case naming strategy this is indistinguishable from resolveColumnName(), which is why it went unnoticed, but it produces the wrong prefix under a PhysicalNamingStrategy that treats column and table naming differently. Switch it to resolveColumnName(getName()). Co-Authored-By: Claude Sonnet 5 --- .../cfg/domainbinding/hibernate/HibernateAssociation.java | 5 +++++ .../domainbinding/hibernate/HibernateToManyProperty.java | 8 +++----- .../hibernate/HibernateToManyPropertySpec.groovy | 6 +++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateAssociation.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateAssociation.java index 6e74814f070..36d9da10840 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateAssociation.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateAssociation.java @@ -27,6 +27,7 @@ import org.grails.datastore.mapping.model.PersistentEntity; import org.grails.datastore.mapping.model.PersistentProperty; import org.grails.orm.hibernate.cfg.Mapping; +import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy; import org.grails.orm.hibernate.cfg.PropertyConfig; /** @@ -80,6 +81,10 @@ default String getReferencedEntityName() { return getHibernateAssociatedEntity().getName(); } + default String resolveAssociatedEntityTableName(PersistentEntityNamingStrategy namingStrategy) { + return getHibernateAssociatedEntity().getHibernateRootEntity().getTableName(namingStrategy); + } + @Override default void validateAssociation() { if (getUserType() != null) { diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java index 57ab76084a3..95b34c79112 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java @@ -212,9 +212,7 @@ default String resolveJoinTableForeignKeyColumnName(PersistentEntityNamingStrate return ofNullable(getHibernateMappedForm()) .map(PropertyConfig::getJoinTableColumnConfig) .map(ColumnConfig::getName) - .orElseGet(() -> getHibernateAssociatedEntity() - .getHibernateRootEntity() - .getTableName(namingStrategy) + + .orElseGet(() -> resolveAssociatedEntityTableName(namingStrategy) + GrailsDomainBinder.FOREIGN_KEY_SUFFIX); } @@ -228,8 +226,8 @@ default String joinTableColumName(PersistentEntityNamingStrategy namingStrategy) } else { var clazz = isBasic() ? namingStrategy.resolveColumnName(referencedType.getName()) : - getHibernateAssociatedEntity().getHibernateRootEntity().getTableName(namingStrategy); - var prop = namingStrategy.resolveTableName(getName()); + resolveAssociatedEntityTableName(namingStrategy); + var prop = namingStrategy.resolveColumnName(getName()); columnName = referencedType.isEnum() ? clazz : new BackticksRemover().apply(prop) + UNDERSCORE + new BackticksRemover().apply(clazz); diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy index 3204a93651b..f0a3cfb3f6b 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy @@ -377,7 +377,7 @@ class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec { property.joinTableColumName(namingStrategy) != null } - void "joinTableColumName applies table naming to an associated entity"() { + void "joinTableColumName applies table naming to the associated entity and column naming to the property prefix"() { given: def property = createTestHibernateToManyProperty(HTMPAuthor, "books") def namingStrategy = Mock(PersistentEntityNamingStrategy) @@ -388,8 +388,8 @@ class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec { then: 1 * namingStrategy.resolveTableName(_ as GrailsHibernatePersistentEntity) >> "book" - 1 * namingStrategy.resolveTableName("books") >> "books" - 0 * namingStrategy.resolveColumnName(_) + 1 * namingStrategy.resolveColumnName("books") >> "books" + 0 * namingStrategy.resolveTableName("books") columnName == "books_book" } From ba081ef6c8f6889c84ff01bc140a0781b21421b6 Mon Sep 17 00:00:00 2001 From: "g.sartori" Date: Wed, 22 Jul 2026 09:59:39 +0200 Subject: [PATCH 5/9] Revert "Merge fixes & cleanups" This reverts commit d1ca11fc663192167367efae9e14874684d03a70. --- THREAT_MODEL.md | 6 +++--- .../controllers/ControllersAutoConfiguration.java | 7 +++++-- .../ControllersAutoConfigurationSpec.groovy | 6 +++--- threat-model.yaml | 14 +++++++------- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index bb098481cf7..1452a1b84e2 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -191,7 +191,7 @@ The framework exposes a small number of configuration knobs whose value affects | `grails.databinding.autoGrowCollectionLimit` | 256 *(documented: [`SimpleDataBinder.groovy`](./grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy))* | Caps automatic collection growth during data binding - hard limit on memory amplification from an attacker submitting deeply indexed parameters (`list[1000000]=x`). Raising removes the cap. | **§14 wave 2** - is the documented default the supported production posture, or is the operator expected to lower it? | | `grails.databinding.dateFormats` / `dateParsingLenient` | RFC-3339 + locale defaults; lenient parsing on | Affects how strict date binding is. Loose parsing has historically been a source of validation-bypass findings in other frameworks. | **§14 wave 2** | | `grails.views.default.codec` and codec defaults (`grails.views.gsp.codecs.expression`, `scriptlet`, `taglib`, `staticparts`) | `html` for expression / scriptlet contexts (XSS protection on by default) | Setting any of these to `none` **disables automatic output encoding** for that context - immediate `OUT-OF-MODEL: non-default-build` for XSS reports under non-default settings. *(documented: [xssPrevention.adoc](./grails-doc/src/en/guide/security/xssPrevention.adoc), [codecs.adoc](./grails-doc/src/en/guide/security/codecs.adoc))* | **§14 wave 1** - confirm the `html` default is the supported production posture. | -| `spring.servlet.multipart.max-file-size` / `max-request-size` | **1 MB per file / 10 MB per request**, provided by Spring Boot's `MultipartProperties` defaults. *(documented: [uploadingFiles.adoc](./grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc))* | Multipart upload size cap. Operators who raise these past their application's actual need expose themselves to DoS via large multipart bodies. | **§14 wave 2** | +| `grails.controllers.upload.maxFileSize` / `maxRequestSize` | **128000 bytes (~125 KB) each**, set by `ControllersAutoConfiguration` (overrides Spring Boot's `MultipartProperties` defaults). *(documented: [`ControllersAutoConfiguration.java`](./grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java))* | Multipart upload size cap. Operators who raise these past their application's actual need expose themselves to DoS via large multipart bodies. | **§14 wave 2** | | `grails.allowedMethods` (per-controller) | None (developer opt-in) | Restricts HTTP methods accepted by each action. Absence is **not** a finding; the model treats per-action method gating as a developer responsibility. *(inferred)* | **§14 wave 1** | | `grails.config.locations` (env var, system property, or config) | Empty | Adds external config file paths. **A non-empty value sourced from an untrusted location is a `BY-DESIGN: property-disclaimed` triage outcome** - see §9. | **§14 wave 1** - confirm this disposition. | | `GRAILS_ENV` / `grails.env` | `development` from CLI, `production` for assembled bootJars | Selects the active environment block in `application.yml` / `application.groovy`. Operators who deploy with `GRAILS_ENV=development` inherit the looser dev defaults (e.g., stack traces in responses). | **§14 wave 1** - is deploying with `development` a `non-default-build` posture? | @@ -212,7 +212,7 @@ The framework's public input boundary is the HTTP request. Per-parameter trust i | `Controller.params` | All values | **Yes** - direct request parameter map | Type coercion correctness; never concatenate into HQL/SQL/JPQL/Groovy strings; never use as redirect target without an allow-list. *(documented: [securingAgainstAttacks.adoc](./grails-doc/src/en/guide/security/securingAgainstAttacks.adoc) "XSS", "HTML/URL injection")* | | `Controller.request.headers` | All values | **Yes** - including `X-Forwarded-*`, `Host`, `User-Agent`, `Referer`, custom auth tokens | Treat presence as evidence of nothing; auth headers must be verified against the configured auth subsystem (Spring Security or equivalent). *(inferred)* | | `Controller.request.cookies` | All values | **Yes** | Treat as attacker-supplied; if used for auth, integrity-protect via Spring Security or signed cookies. *(inferred)* | -| `Controller.request.JSON` / `XML` | Full body | **Yes** | Configure server request-size limits appropriate for the deployment; nested-depth limits are the parser's responsibility (Jackson, JAXP). *(inferred)* | +| `Controller.request.JSON` / `XML` | Full body | **Yes** | Parser inputs are bounded by `maxRequestSize`; nested-depth limits are the parser's responsibility (Jackson, JAXP). *(inferred)* | | `bindData(target, source)` | `source` (any `Map` or request) | **Yes** for the source; **No** for the target type (developer-controlled) | Use `bindable`/`include`/`exclude` to whitelist fields. The framework will bind every settable property of `target` from matching keys in `source` unless told otherwise. *(documented: [GORM data binding guide](https://grails.apache.org/docs/latest/guide/single.html#dataBinding))* | | Command-object binding (auto-bound controller action parameter) | Field values | **Yes** | Annotate command-object fields with `bindable=false` for fields that must not be set from the request. *(inferred)* | | Domain-class binding (`new Book(params)`, `book.properties = params`) | Field values | **Yes** | **Mass-assignment risk.** Use command objects or explicit allow-lists rather than binding the request to a domain class. *(inferred)* | @@ -295,7 +295,7 @@ Each property is stated with its conditions, the symptom of a violation, a sever | P6 | **Data binding respects `bindable=false` and explicit `include`/`exclude` lists.** | [CWE-915](https://cwe.mitre.org/data/definitions/915.html) | Field is annotated or the binding call explicitly lists allowed/forbidden fields. | A field marked unbindable is set from request input. | **Security-critical (CVE-eligible)** | *(inferred)* | | P7 | **Compile-time AST transforms (`@Resource`, `@Validateable`, etc.) only act on developer-authored source.** | [CWE-94](https://cwe.mitre.org/data/definitions/94.html) | Build runs on developer-controlled source. | A transform fires on or is influenced by attacker-supplied input. | **Correctness** (security-critical only if reachable from a non-build attacker) | *(inferred)* | | P8 | **Configuration loading does not evaluate `application.groovy` from a path the framework itself chose at runtime - paths come from build-time classpath and operator-supplied environment/system properties.** | [CWE-94](https://cwe.mitre.org/data/definitions/94.html) | Operator has not pointed `grails.config.locations` at attacker-writable storage. | A user request causes evaluation of a Groovy file the operator did not authorize. | **Security-critical (CVE-eligible)** if violated. | *(inferred)* (§14 wave 1) | -| P9 | **Multipart upload limits and `autoGrowCollectionLimit` bound request-processing memory.** | [CWE-770](https://cwe.mitre.org/data/definitions/770.html) | Operator does not raise the limits past application needs. | Memory growth proportional to attacker-controlled input regardless of limit. | **Resource bug** | *(inferred)* (§14 wave 2) | +| P9 | **`maxFileSize` / `maxRequestSize` / `autoGrowCollectionLimit` provide bounded data-binding memory.** | [CWE-770](https://cwe.mitre.org/data/definitions/770.html) | Operator does not raise the limits past application needs. | Memory growth proportional to attacker-controlled input regardless of limit. | **Resource bug** | *(inferred)* (§14 wave 2) | ### Resource consumption line diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java index 8bb61519748..e51afd8990f 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java @@ -31,10 +31,13 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.context.properties.bind.Bindable; -import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.servlet.autoconfigure.HttpEncodingAutoConfiguration; import org.springframework.boot.servlet.filter.OrderedCharacterEncodingFilter; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletRegistrationBean; +import org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletRegistrationBean; diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy index fb90f00b612..b5ef227a8d8 100644 --- a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy @@ -19,12 +19,13 @@ package org.grails.plugins.web.controllers +import org.springframework.beans.factory.BeanCreationException +import org.springframework.core.env.MapPropertySource import java.util.function.Supplier import grails.core.DefaultGrailsApplication import grails.core.GrailsApplication -import org.springframework.beans.factory.BeanCreationException import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.test.context.runner.WebApplicationContextRunner import org.springframework.boot.web.servlet.AbstractFilterRegistrationBean @@ -33,12 +34,11 @@ import org.springframework.boot.web.servlet.ServletContextInitializerBeans import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration import org.springframework.context.ApplicationContext import org.springframework.context.ConfigurableApplicationContext -import org.springframework.core.env.MapPropertySource import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.mock.web.MockServletContext -import org.springframework.web.context.WebApplicationContext import org.springframework.web.context.support.AnnotationConfigWebApplicationContext +import org.springframework.web.context.WebApplicationContext import org.springframework.web.context.support.StaticWebApplicationContext import org.springframework.web.filter.RequestContextFilter import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver diff --git a/threat-model.yaml b/threat-model.yaml index 8a3faa414f3..0a2a84edb8d 100644 --- a/threat-model.yaml +++ b/threat-model.yaml @@ -124,17 +124,17 @@ config_knobs: security_relevant: true maintainer_stance: unresolved section: "§5a" - - name: spring.servlet.multipart.max-file-size - default: 1048576 + - name: grails.controllers.upload.maxFileSize + default: 128000 default_units: bytes - default_source: Spring Boot MultipartProperties + default_source: grails-controllers/.../ControllersAutoConfiguration.java security_relevant: true maintainer_stance: unresolved section: "§5a" - - name: spring.servlet.multipart.max-request-size - default: 10485760 + - name: grails.controllers.upload.maxRequestSize + default: 128000 default_units: bytes - default_source: Spring Boot MultipartProperties + default_source: grails-controllers/.../ControllersAutoConfiguration.java security_relevant: true maintainer_stance: unresolved section: "§5a" @@ -298,7 +298,7 @@ properties_provided: provenance: inferred open_question: "§14 wave 1" - id: P9 - description: "Multipart upload limits and autoGrowCollectionLimit bound request-processing memory." + description: "maxFileSize, maxRequestSize, autoGrowCollectionLimit provide bounded data-binding memory." cwe: CWE-770 conditions: "Operator does not raise the limits past application needs." violation_symptom: "Memory growth proportional to input regardless of configured limit." From 3195e1d17abdba7163012dd162024482d4db49a2 Mon Sep 17 00:00:00 2001 From: "g.sartori" Date: Wed, 22 Jul 2026 09:59:45 +0200 Subject: [PATCH 6/9] Revert "PR for: https://github.com/apache/grails-core/issues/15644" This reverts commit bfdbbe88fa63c33b92e3a0ed7e0509ef99b500ac. --- .../ControllersAutoConfiguration.java | 43 +++++++++---------- .../ControllersAutoConfigurationSpec.groovy | 24 ----------- .../main/groovy/grails/config/Settings.groovy | 20 +++++++++ .../controllers/uploadingFiles.adoc | 19 ++++---- .../spring-configuration-metadata.json | 24 +++++++++++ 5 files changed, 72 insertions(+), 58 deletions(-) diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java index e51afd8990f..1d7527b2d78 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java @@ -26,26 +26,18 @@ import jakarta.servlet.Filter; import jakarta.servlet.MultipartConfigElement; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.servlet.autoconfigure.HttpEncodingAutoConfiguration; import org.springframework.boot.servlet.filter.OrderedCharacterEncodingFilter; -import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletRegistrationBean; -import org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration; -import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; -import org.springframework.boot.context.properties.bind.Bindable; -import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration; import org.springframework.boot.webmvc.autoconfigure.DispatcherServletRegistrationBean; import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; import org.springframework.context.ApplicationContext; -import org.springframework.context.EnvironmentAware; import org.springframework.context.annotation.Bean; -import org.springframework.core.env.Environment; import org.springframework.util.ClassUtils; import org.springframework.web.filter.CharacterEncodingFilter; import org.springframework.web.servlet.DispatcherServlet; @@ -66,15 +58,7 @@ after = {GrailsDomainClassAutoConfiguration.class} ) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) -public class ControllersAutoConfiguration implements EnvironmentAware { - - private static final String LEGACY_MULTIPART_CONFIGURATION = "grails.controllers.upload"; - - static final String LEGACY_MULTIPART_CONFIGURATION_ERROR = - "Configuration properties under 'grails.controllers.upload' are no longer supported. " + - "Use Spring Boot's 'spring.servlet.multipart' configuration instead. For example, set " + - "'spring.servlet.multipart.max-file-size=200MB' and " + - "'spring.servlet.multipart.max-request-size=200MB'."; +public class ControllersAutoConfiguration { @Value("${" + Settings.FILTER_ENCODING + ":utf-8}") private String filtersEncoding; @@ -91,6 +75,18 @@ public class ControllersAutoConfiguration implements EnvironmentAware { @Value("${" + Settings.RESOURCES_PATTERN + ":" + Settings.DEFAULT_RESOURCE_PATTERN + "}") private String resourcesPattern; + @Value("${" + Settings.CONTROLLERS_UPLOAD_LOCATION + ":#{null}}") + private String uploadTmpDir; + + @Value("${" + Settings.CONTROLLERS_UPLOAD_MAX_FILE_SIZE + ":128000}") + private long maxFileSize; + + @Value("${" + Settings.CONTROLLERS_UPLOAD_MAX_REQUEST_SIZE + ":128000}") + private long maxRequestSize; + + @Value("${" + Settings.CONTROLLERS_UPLOAD_FILE_SIZE_THRESHOLD + ":0}") + private int fileSizeThreshold; + @Value("${" + Settings.WEB_SERVLET_PATH + ":#{null}}") String grailsServletPath; @@ -154,11 +150,12 @@ public FilterRegistrationBean grailsWebRequestFilter(Gra return registrationBean; } - @Override - public void setEnvironment(Environment environment) { - if (Binder.get(environment).bind(LEGACY_MULTIPART_CONFIGURATION, Bindable.mapOf(String.class, Object.class)).isBound()) { - throw new IllegalStateException(LEGACY_MULTIPART_CONFIGURATION_ERROR); + @Bean + public MultipartConfigElement multipartConfigElement() { + if (uploadTmpDir == null) { + uploadTmpDir = System.getProperty("java.io.tmpdir"); } + return new MultipartConfigElement(uploadTmpDir, maxFileSize, maxRequestSize, fileSizeThreshold); } @Bean @@ -167,7 +164,7 @@ public DispatcherServlet dispatcherServlet() { } @Bean - public DispatcherServletRegistrationBean dispatcherServletRegistration(GrailsApplication application, DispatcherServlet dispatcherServlet, ObjectProvider multipartConfigElement) { + public DispatcherServletRegistrationBean dispatcherServletRegistration(GrailsApplication application, DispatcherServlet dispatcherServlet, MultipartConfigElement multipartConfigElement) { if (grailsServletPath == null) { boolean isTomcat = ClassUtils.isPresent("org.apache.catalina.startup.Tomcat", application.getClassLoader()); grailsServletPath = isTomcat ? Settings.DEFAULT_TOMCAT_SERVLET_PATH : Settings.DEFAULT_WEB_SERVLET_PATH; @@ -175,7 +172,7 @@ public DispatcherServletRegistrationBean dispatcherServletRegistration(GrailsApp DispatcherServletRegistrationBean dispatcherServletRegistration = new DispatcherServletRegistrationBean(dispatcherServlet, grailsServletPath); dispatcherServletRegistration.setLoadOnStartup(2); dispatcherServletRegistration.setAsyncSupported(true); - multipartConfigElement.ifAvailable(dispatcherServletRegistration::setMultipartConfig); + dispatcherServletRegistration.setMultipartConfig(multipartConfigElement); return dispatcherServletRegistration; } diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy index b5ef227a8d8..1d2c17b61f1 100644 --- a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy @@ -19,8 +19,6 @@ package org.grails.plugins.web.controllers -import org.springframework.beans.factory.BeanCreationException -import org.springframework.core.env.MapPropertySource import java.util.function.Supplier import grails.core.DefaultGrailsApplication @@ -37,7 +35,6 @@ import org.springframework.context.ConfigurableApplicationContext import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.mock.web.MockServletContext -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext import org.springframework.web.context.WebApplicationContext import org.springframework.web.context.support.StaticWebApplicationContext import org.springframework.web.filter.RequestContextFilter @@ -57,27 +54,6 @@ class ControllersAutoConfigurationSpec extends Specification { def autoConfiguration = new ControllersAutoConfiguration() - def "legacy multipart configuration fails startup with migration instructions"() { - given: - def applicationContext = new AnnotationConfigWebApplicationContext() - applicationContext.servletContext = new MockServletContext() - applicationContext.environment.propertySources.addFirst(new MapPropertySource('test', [ - 'grails.controllers.upload.maxFileSize': 20000000, - ])) - applicationContext.register(ControllersAutoConfiguration) - - when: - applicationContext.refresh() - - then: - BeanCreationException exception = thrown() - exception.rootCause instanceof IllegalStateException - exception.rootCause.message == ControllersAutoConfiguration.LEGACY_MULTIPART_CONFIGURATION_ERROR - - cleanup: - applicationContext.close() - } - void 'grailsWebRequest filter is a RequestContextFilter so Boot WebMvcAutoConfiguration backs off its own RequestContextFilter'() { when: 'the Grails request-binding filter bean is created' GrailsWebRequestFilter filter = autoConfiguration.grailsWebRequest(applicationContext) diff --git a/grails-core/src/main/groovy/grails/config/Settings.groovy b/grails-core/src/main/groovy/grails/config/Settings.groovy index ff65c2a40bc..6acf468f8b8 100644 --- a/grails-core/src/main/groovy/grails/config/Settings.groovy +++ b/grails-core/src/main/groovy/grails/config/Settings.groovy @@ -209,6 +209,26 @@ interface Settings { */ String CONTROLLERS_DEFAULT_SCOPE = 'grails.controllers.defaultScope' + /** + * The upload directory for controllers, defaults to java.tmp.dir + */ + String CONTROLLERS_UPLOAD_LOCATION = 'grails.controllers.upload.location' + + /** + * The maximum file size + */ + String CONTROLLERS_UPLOAD_MAX_FILE_SIZE = 'grails.controllers.upload.maxFileSize' + + /** + * The maximum request size + */ + String CONTROLLERS_UPLOAD_MAX_REQUEST_SIZE = 'grails.controllers.upload.maxRequestSize' + + /** + * The file size threshold + */ + String CONTROLLERS_UPLOAD_FILE_SIZE_THRESHOLD = 'grails.controllers.upload.fileSizeThreshold' + /** * The encoding to use for filters, default to UTF-8 */ diff --git a/grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc b/grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc index 2d0bf980690..606232098e7 100644 --- a/grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc +++ b/grails-doc/src/en/guide/theWebLayer/controllers/uploadingFiles.adoc @@ -91,7 +91,7 @@ class Image { ==== Increase Upload Max File Size -Spring Boot's default size for file uploads is 1MB and its default maximum multipart request size is 10MB. When a limit is exceeded you'll see the following exception: +Grails default size for file uploads is 128000 (~128KB). When this limit is exceeded you'll see the following exception: [source,java] ---- @@ -103,19 +103,16 @@ You can configure the limit in your `application.yml` as follows: [source,yml] .grails-app/conf/application.yml ---- -spring: - servlet: - multipart: - max-file-size: 200MB - max-request-size: 200MB +grails: + controllers: + upload: + maxFileSize: 2000000 + maxRequestSize: 2000000 ---- -`max-file-size` = The maximum size allowed for an uploaded file. +`maxFileSize` = The maximum size allowed for uploaded files. -`max-request-size` = The maximum size allowed for a multipart/form-data request. - -The `spring.servlet.multipart` namespace also supports Spring Boot's `location`, `file-size-threshold`, and `enabled` properties. -The former `grails.controllers.upload` configuration is no longer supported. Applications using it fail at startup with a message explaining how to migrate to `spring.servlet.multipart`. +`maxRequestSize` = The maximum size allowed for multipart/form-data requests. You should keep in mind https://www.owasp.org/index.php/Unrestricted_File_Upload[OWASP recommendations - Unrestricted File Upload] diff --git a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json index ced384e6567..831f5866b67 100644 --- a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json @@ -52,6 +52,30 @@ "description": "The default scope for controllers (singleton, prototype, session).", "defaultValue": "singleton" }, + { + "name": "grails.controllers.upload.location", + "type": "java.lang.String", + "description": "The directory for temporary file uploads.", + "defaultValue": "System.getProperty('java.io.tmpdir')" + }, + { + "name": "grails.controllers.upload.maxFileSize", + "type": "java.lang.Integer", + "description": "Maximum file size for uploads (in bytes).", + "defaultValue": 1048576 + }, + { + "name": "grails.controllers.upload.maxRequestSize", + "type": "java.lang.Integer", + "description": "Maximum request size for multipart uploads (in bytes).", + "defaultValue": 10485760 + }, + { + "name": "grails.controllers.upload.fileSizeThreshold", + "type": "java.lang.Integer", + "description": "File size threshold (in bytes) above which uploads are written to disk.", + "defaultValue": 0 + }, { "name": "grails.web.url.converter", "type": "java.lang.String", From 0ca2559f1eb6bbd1306c32a03ee6080fd1207763 Mon Sep 17 00:00:00 2001 From: "g.sartori" Date: Wed, 22 Jul 2026 22:23:39 +0200 Subject: [PATCH 7/9] Upgrade guide --- .../src/en/guide/upgrading/upgrading80x.adoc | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 2bb87483681..b446bbf4353 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1215,6 +1215,64 @@ GORM's `createCriteria()` and `withCriteria()` DSL are implemented on top of the *`javax.persistence` → `jakarta.persistence`*: This migration was already required for Grails 7; Grails 8 continues to require `jakarta.*`. +===== 26.9 Many-to-Many Join-Table Column Names + +In Grails 7 (Hibernate 5), the default foreign-key column names of a many-to-many join table were derived from the simple names of the associated domain classes, after applying the column naming strategy. +For example, the domain class `Book` produced the foreign-key column `book_id`, even when its physical table was mapped to a different name. + +In Grails 8 (Hibernate 7), these foreign-key column names are instead derived from the physical table names of the associated domain classes. +The physical name includes an explicit `table` mapping and any transformation made by a custom physical naming strategy. +For example, given the following mapping: + +[source,groovy] +---- +class Book { + static mapping = { + table 'catalog_book' + } +} +---- + +Grails 7 used `book_id` by default in the join table, whereas Grails 8 uses `catalog_book_id`. +This is a breaking schema change for an existing database if its join table still uses the Grails 7 column names. + +To keep the existing schema unchanged, configure the join table and both foreign-key columns explicitly in the `static mapping` block on each side of the relationship: + +[source,groovy] +---- +class Author { + String name + + static hasMany = [books: Book] + + static mapping = { + books joinTable: [ + name: 'author_books', + key: 'author_id', + column: 'book_id' + ] + } +} + +class Book { + String title + + static belongsTo = Author + static hasMany = [authors: Author] + + static mapping = { + authors joinTable: [ + name: 'author_books', + key: 'book_id', + column: 'author_id' + ] + } +} +---- + +Replace `author_books`, `author_id`, and `book_id` with the table and column names already used by your database. +Declaring all three names prevents the naming strategy from changing the mapping during the upgrade. + ==== 27. GORM Properties Are Nullable by Default Grails 8 changes the validation default so that an *unconstrained persistent (domain) property is nullable by default* rather than required. From 546fea44a98c157e81c7739443514038f273c5ea Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Sun, 26 Jul 2026 19:40:05 -0500 Subject: [PATCH 8/9] Strip backticks from resolved associated-entity table names resolveAssociatedEntityTableName's result is only ever used as a column-identifier fragment (a join-table foreign-key or element column name) by its callers, never as a literal SQL identifier - so a backtick-quoted table mapping (e.g. table '`user`') produced a malformed column like `user`_id via resolveJoinTableForeignKeyColumnName, generating invalid DDL that fails silently without hbm2ddl.halt_on_error. Strips backticks once at the source instead of trusting each caller to do it themselves, since every caller wants the clean form. Adds a domain-class-only regression test (no mocks) reproducing the bug via a real unidirectional hasMany join-table binding. Co-Authored-By: Claude Sonnet 5 --- .../hibernate/HibernateAssociation.java | 8 +++++- .../HibernateToManyPropertySpec.groovy | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateAssociation.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateAssociation.java index 36d9da10840..b7dca09707d 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateAssociation.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateAssociation.java @@ -29,6 +29,7 @@ import org.grails.orm.hibernate.cfg.Mapping; import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy; import org.grails.orm.hibernate.cfg.PropertyConfig; +import org.grails.orm.hibernate.cfg.domainbinding.util.BackticksRemover; /** * Common interface for all Hibernate association properties (both ToOne and ToMany). Extends {@link @@ -82,7 +83,12 @@ default String getReferencedEntityName() { } default String resolveAssociatedEntityTableName(PersistentEntityNamingStrategy namingStrategy) { - return getHibernateAssociatedEntity().getHibernateRootEntity().getTableName(namingStrategy); + // Every caller of this method uses the result as a column-identifier fragment (a join-table foreign-key + // or element column name), never as a literal, quotable SQL identifier - so the Groovy backtick-quoting + // convention is always invalid there and must be stripped once at the source, rather than trusted to + // each caller (a prior bug left one caller emitting a malformed column like `quoted_table`_id). + return new BackticksRemover().apply( + getHibernateAssociatedEntity().getHibernateRootEntity().getTableName(namingStrategy)); } @Override diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy index f0a3cfb3f6b..86ef760c7b4 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy @@ -89,6 +89,16 @@ class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec { property.resolveJoinTableForeignKeyColumnName(namingStrategy) == "htmp_book_id" } + void "resolveJoinTableForeignKeyColumnName strips backticks from a backtick-quoted associated entity table name"() { + given: + def property = createTestHibernateToManyProperty(HTMPQuotedTableAuthor, "books") + def namingStrategy = getGrailsDomainBinder().namingStrategy + hibernateFirstPass() + + expect: + property.resolveJoinTableForeignKeyColumnName(namingStrategy) == "htmp_quoted_book_id" + } + void "isAssociationColumnNullable returns false for ManyToMany"() { given: "Register only entities for this specific test" createPersistentEntity(HTMPCourse) // Course is needed because Student refers to it @@ -640,6 +650,23 @@ class HTMPMappedTableAuthor { static hasMany = [books: Book] } +@Entity +class HTMPQuotedTableBook { + Long id + String title + + static mapping = { + table '`htmp_quoted_book`' + } +} + +@Entity +class HTMPQuotedTableAuthor { + Long id + String name + static hasMany = [books: HTMPQuotedTableBook] +} + class HTMPPrefixRemovingPhysicalNamingStrategy extends PhysicalNamingStrategyStandardImpl { @Override From 4da31cc034774847c4630d76ca1e5d74514280a4 Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Thu, 30 Jul 2026 18:09:49 -0500 Subject: [PATCH 9/9] Narrow join-table naming fix's scope in code and docs to unidirectional hasMany jdaugherty's review confirmed resolveJoinTableForeignKeyColumnName only runs for a unidirectional hasMany join table - a bidirectional many-to-many still derives both columns from class names, unaffected by this change. Documents that scope explicitly (with a corrected, genuinely-unidirectional example) in both the upgrade guide and the naming-strategy guide, and cross-references the two, instead of describing a many-to-many schema change that doesn't happen. joinTableColumName's isBasic() ternary was dead: both of its callers (BasicCollectionElementBinder, EnumTypeBinder) always pass a HibernateBasicProperty, so the association branch never ran and was only reachable through a mocked naming strategy in the existing test. Dropped the ternary and replaced that test with one that boots a real PhysicalNamingStrategy distinguishing column from table naming and asserts the resulting join column, rather than asserting mock interaction counts on unreachable code. Renamed the test's Book domain class to HTMPMappedTableBook to match the rest of the file's HTMP-prefix convention. Co-Authored-By: Claude Sonnet 5 --- .../hibernate/HibernateToManyProperty.java | 12 +++-- .../HibernateToManyPropertySpec.groovy | 34 +++++++------ .../ormdsl/customNamingStrategy.adoc | 4 +- .../src/en/guide/upgrading/upgrading80x.adoc | 48 +++++++++---------- 4 files changed, 53 insertions(+), 45 deletions(-) diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java index 95b34c79112..807d99c28ac 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyProperty.java @@ -208,6 +208,11 @@ default String getMapElementName(PersistentEntityNamingStrategy namingStrategy) IndexedCollection.DEFAULT_ELEMENT_COLUMN_NAME); } + /** + * Only reached for a unidirectional {@code hasMany} join table (via {@code CollectionWithJoinTableBinder}). + * A bidirectional many-to-many join table's foreign-key columns instead go through + * {@code DefaultColumnNameFetcher#resolveForeignKeyForPropertyDomainClass}, unaffected by this method. + */ default String resolveJoinTableForeignKeyColumnName(PersistentEntityNamingStrategy namingStrategy) { return ofNullable(getHibernateMappedForm()) .map(PropertyConfig::getJoinTableColumnConfig) @@ -224,9 +229,10 @@ default String joinTableColumName(PersistentEntityNamingStrategy namingStrategy) if (present) { columnName = joinColumnMappingOptional.get().getName(); } else { - var clazz = isBasic() ? - namingStrategy.resolveColumnName(referencedType.getName()) : - resolveAssociatedEntityTableName(namingStrategy); + // Both callers of joinTableColumName (BasicCollectionElementBinder, EnumTypeBinder) operate on + // a HibernateBasicProperty, so referencedType is always the collection's basic element type here, + // never an associated entity - resolveAssociatedEntityTableName does not apply to this path. + var clazz = namingStrategy.resolveColumnName(referencedType.getName()); var prop = namingStrategy.resolveColumnName(getName()); columnName = referencedType.isEnum() ? clazz : diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy index 86ef760c7b4..104209f507f 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/hibernate/HibernateToManyPropertySpec.groovy @@ -31,7 +31,6 @@ import org.grails.datastore.mapping.model.ClassMapping import org.grails.datastore.mapping.reflect.EntityReflector import org.grails.orm.hibernate.cfg.PropertyConfig import org.grails.orm.hibernate.cfg.Mapping -import org.grails.orm.hibernate.cfg.PersistentEntityNamingStrategy import org.grails.orm.hibernate.cfg.domainbinding.util.NamingStrategyWrapper class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec { @@ -387,20 +386,15 @@ class HibernateToManyPropertySpec extends HibernateGormDatastoreSpec { property.joinTableColumName(namingStrategy) != null } - void "joinTableColumName applies table naming to the associated entity and column naming to the property prefix"() { - given: - def property = createTestHibernateToManyProperty(HTMPAuthor, "books") - def namingStrategy = Mock(PersistentEntityNamingStrategy) + void "joinTableColumName resolves the property prefix through column naming rather than table naming"() { + given: "a physical naming strategy where column and table transformation rules diverge for 'tags'" + def property = createTestHibernateToManyProperty(HTMPOwnerString, "tags") + def namingStrategy = new NamingStrategyWrapper( + new HTMPColumnMarkingPhysicalNamingStrategy(), getGrailsDomainBinder().jdbcEnvironment) hibernateFirstPass() - when: - String columnName = property.joinTableColumName(namingStrategy) - - then: - 1 * namingStrategy.resolveTableName(_ as GrailsHibernatePersistentEntity) >> "book" - 1 * namingStrategy.resolveColumnName("books") >> "books" - 0 * namingStrategy.resolveTableName("books") - columnName == "books_book" + expect: "the property prefix carries the column-naming marker; the unmarked form would mean the old resolveTableName() path ran instead" + property.joinTableColumName(namingStrategy).startsWith("tags_as_column_") } void "joinTableColumName returns derived column name for enum collection"() { @@ -634,7 +628,7 @@ class HTMPBook { } @Entity -class Book { +class HTMPMappedTableBook { Long id String title @@ -647,7 +641,7 @@ class Book { class HTMPMappedTableAuthor { Long id String name - static hasMany = [books: Book] + static hasMany = [books: HTMPMappedTableBook] } @Entity @@ -677,6 +671,16 @@ class HTMPPrefixRemovingPhysicalNamingStrategy extends PhysicalNamingStrategySta } } +class HTMPColumnMarkingPhysicalNamingStrategy extends PhysicalNamingStrategyStandardImpl { + + @Override + Identifier toPhysicalColumnName(Identifier logicalName, JdbcEnvironment jdbcEnvironment) { + logicalName.text == 'tags' ? + Identifier.toIdentifier('tags_as_column') : + super.toPhysicalColumnName(logicalName, jdbcEnvironment) + } +} + @Entity class HTMPAuthor { Long id diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc index 6dc391b3c55..8672757afff 100644 --- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc +++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/ormdsl/customNamingStrategy.adoc @@ -69,4 +69,6 @@ class UpperCaseNamingStrategy implements PhysicalNamingStrategy { TIP: Individual column or table names set explicitly in the `mapping` block always take precedence over what the naming strategy would produce. -The default foreign-key column names in a `hasMany` join table are derived from the physical table names of the associated domain classes. Consequently, a custom strategy that changes a domain table name also changes the corresponding join-table foreign-key column prefix. For example, if the strategy maps `TBook` to the table `book`, the default foreign-key column is `book_id`, not `tbook_id`. Applications upgrading from an earlier GORM version should account for this schema change or configure the join-table columns explicitly in the `mapping` block. +For a *unidirectional* `hasMany` (a collection with no `belongsTo` or reciprocal `hasMany` on the other side), the default foreign-key column that references the associated entity is derived from that entity's physical table name. Consequently, a custom strategy that changes a domain table name also changes that column. For example, given `static hasMany = [books: TBook]`, if the strategy maps `TBook` to the table `book`, the default foreign-key column is `book_id`, not `tbook_id`. + +This does not apply to a *bidirectional* many-to-many association: both of its join-table foreign-key columns are still derived from the class names, regardless of any `table` mapping or naming strategy. Applications upgrading from an earlier GORM version should account for the unidirectional case's schema change, or configure the join-table columns explicitly in the `mapping` block. See https://grails.apache.org/docs/latest/guide/single.html#_join_table_foreign_key_column_names[Join-Table Foreign-Key Column Names] in the upgrade guide for details and a migration example. diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index b446bbf4353..658f32f72d5 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1215,12 +1215,15 @@ GORM's `createCriteria()` and `withCriteria()` DSL are implemented on top of the *`javax.persistence` → `jakarta.persistence`*: This migration was already required for Grails 7; Grails 8 continues to require `jakarta.*`. -===== 26.9 Many-to-Many Join-Table Column Names +[[_join_table_foreign_key_column_names]] +===== 26.9 Join-Table Foreign-Key Column Names -In Grails 7 (Hibernate 5), the default foreign-key column names of a many-to-many join table were derived from the simple names of the associated domain classes, after applying the column naming strategy. +This change affects only a *unidirectional* `hasMany` — a collection with no `belongsTo` and no reciprocal `hasMany` on the other side. A *bidirectional* many-to-many association (both sides declare `hasMany`, or one side uses `belongsTo`) is **not** affected: both of its join-table foreign-key columns are still derived from the class names, identical to Grails 7. + +In Grails 7 (Hibernate 5), the default foreign-key column that referenced the associated entity in a unidirectional `hasMany` join table was derived from the simple name of that entity's domain class, after applying the column naming strategy. For example, the domain class `Book` produced the foreign-key column `book_id`, even when its physical table was mapped to a different name. -In Grails 8 (Hibernate 7), these foreign-key column names are instead derived from the physical table names of the associated domain classes. +In Grails 8 (Hibernate 7), that foreign-key column is instead derived from the physical table name of the associated domain class. The physical name includes an explicit `table` mapping and any transformation made by a custom physical naming strategy. For example, given the following mapping: @@ -1231,48 +1234,41 @@ class Book { table 'catalog_book' } } + +class Shelf { + String label + + static hasMany = [books: Book] // unidirectional: no belongsTo, no reciprocal hasMany +} ---- -Grails 7 used `book_id` by default in the join table, whereas Grails 8 uses `catalog_book_id`. -This is a breaking schema change for an existing database if its join table still uses the Grails 7 column names. +Grails 7 used `book_id` by default in the `shelf_books` join table, whereas Grails 8 uses `catalog_book_id`. The other column in that same join table (`shelf_id`, derived from the owning `Shelf` class) is unchanged. +This is a breaking schema change for an existing database if its join table still uses the Grails 7 column name. -To keep the existing schema unchanged, configure the join table and both foreign-key columns explicitly in the `static mapping` block on each side of the relationship: +To keep the existing schema unchanged, configure the join table and its foreign-key column explicitly in the `static mapping` block: [source,groovy] ---- -class Author { - String name +class Shelf { + String label static hasMany = [books: Book] static mapping = { books joinTable: [ - name: 'author_books', - key: 'author_id', + name: 'shelf_books', + key: 'shelf_id', column: 'book_id' ] } } - -class Book { - String title - - static belongsTo = Author - static hasMany = [authors: Author] - - static mapping = { - authors joinTable: [ - name: 'author_books', - key: 'book_id', - column: 'author_id' - ] - } -} ---- -Replace `author_books`, `author_id`, and `book_id` with the table and column names already used by your database. +Replace `shelf_books`, `shelf_id`, and `book_id` with the table and column names already used by your database. Declaring all three names prevents the naming strategy from changing the mapping during the upgrade. +Separately, the element column of a *basic or enum collection* (e.g. `static hasMany = [items: String]`) is now resolved through the naming strategy's *column* naming rules instead of its *table* naming rules for the property-name prefix (e.g. `items_value`). Most naming strategies apply the same transformation to both, so this only matters if your custom `PhysicalNamingStrategy` implements `toPhysicalColumnName` and `toPhysicalTableName` differently. + ==== 27. GORM Properties Are Nullable by Default Grails 8 changes the validation default so that an *unconstrained persistent (domain) property is nullable by default* rather than required.