From d175d245330fe5dd0d6812df6e599975fe2c7cb4 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 1 Jul 2026 19:13:38 -0400 Subject: [PATCH 1/3] Mask exception request parameters case-insensitively Match excluded request parameter names without regard to case when Grails logs exception request parameters, and document the matching behavior in the logging guide and configuration metadata. Assisted-by: Hephaestus:openai/gpt-5.5 --- ...ngRequestParametersFromStacktraceLogs.adoc | 1 + .../spring-configuration-metadata.json | 2 +- .../web/errors/GrailsExceptionResolver.java | 14 +- .../errors/GrailsExceptionResolverSpec.groovy | 293 +++++++++++++++++- 4 files changed, 307 insertions(+), 3 deletions(-) diff --git a/grails-doc/src/en/guide/conf/config/logging/maskingRequestParametersFromStacktraceLogs.adoc b/grails-doc/src/en/guide/conf/config/logging/maskingRequestParametersFromStacktraceLogs.adoc index 50efe741566..b43f3bf5951 100644 --- a/grails-doc/src/en/guide/conf/config/logging/maskingRequestParametersFromStacktraceLogs.adoc +++ b/grails-doc/src/en/guide/conf/config/logging/maskingRequestParametersFromStacktraceLogs.adoc @@ -19,6 +19,7 @@ under the License. When Grails logs a stacktrace, the log message may include the names and values of all of the request parameters for the current request. To mask out the values of secure request parameters, specify the parameter names in the `grails.exceptionresolver.params.exclude` config property: +Parameter names are matched case-insensitively. [source, yaml] .grails-app/conf/application.yml 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 1dd9840914e..bb7e1a10c46 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 @@ -115,7 +115,7 @@ { "name": "grails.exceptionresolver.params.exclude", "type": "java.util.List", - "description": "List of parameter names to mask (replace with [*****]) in exception stack traces, typically used for password and creditCard.", + "description": "List of parameter names to mask (replace with [*****]) in exception stack traces, typically used for password and creditCard. Parameter names are matched case-insensitively.", "defaultValue": [] }, { diff --git a/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java b/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java index 9d41a20e928..29c0cf83adf 100644 --- a/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java +++ b/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java @@ -310,7 +310,7 @@ protected String getRequestLogMessage(String exceptionName, HttpServletRequest r for (i = 0; i < values.length; i++) { sb.append(LINE_SEPARATOR).append(param).append(": "); - if (blackList.contains(param)) { + if (isExcludedRequestParameter(param, blackList)) { sb.append("***"); } else { sb.append(values[i]); @@ -330,6 +330,18 @@ protected String getRequestLogMessage(String exceptionName, HttpServletRequest r return sb.toString(); } + protected boolean isExcludedRequestParameter(String parameterName, List excludedParameterNames) { + if (parameterName == null || excludedParameterNames == null) { + return false; + } + for (String excludedParameterName : excludedParameterNames) { + if (parameterName.equalsIgnoreCase(excludedParameterName)) { + return true; + } + } + return false; + } + protected void createStackFilterer() { try { Class filtererClass = grailsApplication.getConfig().getProperty(Settings.SETTING_LOGGING_STACKTRACE_FILTER_CLASS, Class.class, DefaultStackTraceFilterer.class); diff --git a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy index 0ef3ea062c9..261c73cb86c 100644 --- a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy +++ b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy @@ -18,8 +18,11 @@ */ package org.grails.web.errors +import grails.config.Config +import grails.core.GrailsApplication import grails.web.mapping.UrlMappingsHolder import grails.web.mapping.exceptions.UrlMappingException +import org.grails.exceptions.reporting.DefaultStackTraceFilterer import org.springframework.mock.web.MockHttpServletRequest import spock.lang.Specification @@ -43,4 +46,292 @@ class GrailsExceptionResolverSpec extends Specification { noExceptionThrown() params.isEmpty() } -} \ No newline at end of file + void "logStackTrace emits only the resolver log"() { + given: "Captured System.err" + def originalErr = System.err + def baos = new ByteArrayOutputStream() + System.setErr(new PrintStream(baos, true)) + + and: "A resolver with no grailsApplication wired" + def resolver = new GrailsExceptionResolver() + def request = new MockHttpServletRequest('GET', '/test') + def exception = new RuntimeException('boom') + + when: + resolver.logStackTrace(exception, request) + + then: "Only the GrailsExceptionResolver logger emits; StackTrace logger is silent" + System.err.flush() + def captured = baos.toString() + captured.contains('o.g.web.errors.GrailsExceptionResolver') || + captured.contains('org.grails.web.errors.GrailsExceptionResolver') + !captured.contains('ERROR StackTrace ') + + cleanup: + System.setErr(originalErr) + } + + void "logFullStackTraceIfEnabled is a no-op when the opt-in property is unset"() { + given: "Captured System.err" + def originalErr = System.err + def baos = new ByteArrayOutputStream() + System.setErr(new PrintStream(baos, true)) + + and: "A resolver with no grailsApplication wired" + def resolver = new GrailsExceptionResolver() + def exception = new RuntimeException('boom') + + when: + resolver.logFullStackTraceIfEnabled(exception) + + then: "No StackTrace log entry is emitted" + System.err.flush() + !baos.toString().contains('ERROR StackTrace ') + + cleanup: + System.setErr(originalErr) + } + + void "getRequestLogMessage appends auditor when logAuditor is enabled and the lookup returns a value"() { + given: + def config = Mock(Config) + config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> true + config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false + def grailsApp = Mock(GrailsApplication) + grailsApp.getConfig() >> config + def resolver = new GrailsExceptionResolver() + resolver.grailsApplication = grailsApp + resolver.auditorAwareLookup = new AuditorAwareLookup(null) { + @Override + Optional getCurrentAuditor() { Optional.of('alice') } + } + def request = new MockHttpServletRequest('GET', '/test') + + when: + def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') + + then: + msg.contains('(user: alice)') + } + + void "getRequestLogMessage omits auditor when logAuditor is disabled"() { + given: + def config = Mock(Config) + config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false + def grailsApp = Mock(GrailsApplication) + grailsApp.getConfig() >> config + def resolver = new GrailsExceptionResolver() + resolver.grailsApplication = grailsApp + resolver.auditorAwareLookup = new AuditorAwareLookup(null) { + @Override + Optional getCurrentAuditor() { Optional.of('alice') } + } + def request = new MockHttpServletRequest('GET', '/test') + + when: + def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') + + then: + !msg.contains('(user:') + } + + void "getRequestLogMessage omits auditor when logAuditor is enabled but auditor is absent"() { + given: + def config = Mock(Config) + config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> true + config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false + def grailsApp = Mock(GrailsApplication) + grailsApp.getConfig() >> config + def resolver = new GrailsExceptionResolver() + resolver.grailsApplication = grailsApp + resolver.auditorAwareLookup = new AuditorAwareLookup(null) { + @Override + Optional getCurrentAuditor() { Optional.empty() } + } + def request = new MockHttpServletRequest('GET', '/test') + + when: + def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') + + then: + !msg.contains('(user:') + } + + void "getRequestLogMessage appends remote address when logRemoteAddr is enabled"() { + given: + def config = Mock(Config) + config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> true + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false + def grailsApp = Mock(GrailsApplication) + grailsApp.getConfig() >> config + def resolver = new GrailsExceptionResolver() + resolver.grailsApplication = grailsApp + def request = new MockHttpServletRequest('GET', '/test') + request.remoteAddr = '198.51.100.42' + + when: + def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') + + then: + msg.contains('(ip: 198.51.100.42)') + !msg.contains('user:') + } + + void "getRequestLogMessage combines remote address and auditor into a single clause when both are enabled"() { + given: + def config = Mock(Config) + config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> true + config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> true + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false + def grailsApp = Mock(GrailsApplication) + grailsApp.getConfig() >> config + def resolver = new GrailsExceptionResolver() + resolver.grailsApplication = grailsApp + resolver.auditorAwareLookup = new AuditorAwareLookup(null) { + @Override + Optional getCurrentAuditor() { Optional.of(42L) } + } + def request = new MockHttpServletRequest('GET', '/test') + request.remoteAddr = '198.51.100.42' + + when: + def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') + + then: + msg.contains('(ip: 198.51.100.42, user: 42)') + } + + void "subclasses can override resolveRemoteAddr to supply a custom IP extraction"() { + given: + def config = Mock(Config) + config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> true + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false + def grailsApp = Mock(GrailsApplication) + grailsApp.getConfig() >> config + def resolver = new GrailsExceptionResolver() { + @Override + protected String resolveRemoteAddr(HttpServletRequest req) { + req.getHeader('X-Forwarded-For') ?: req.remoteAddr + } + } + resolver.grailsApplication = grailsApp + def request = new MockHttpServletRequest('GET', '/test') + request.remoteAddr = '10.0.0.1' + request.addHeader('X-Forwarded-For', '203.0.113.7') + + when: + def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') + + then: + msg.contains('(ip: 203.0.113.7)') + } + + void "getRequestLogMessage masks excluded request parameters case-insensitively"() { + given: + def config = Mock(Config) + config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> true + config.getProperty('grails.exceptionresolver.params.exclude', List, _) >> ['password', 'token'] + config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false + def grailsApp = Mock(GrailsApplication) + grailsApp.getConfig() >> config + def resolver = new GrailsExceptionResolver() + resolver.grailsApplication = grailsApp + def request = new MockHttpServletRequest('POST', '/login') + request.addParameter('Password', 'secret') + request.addParameter('apiToken', 'visible') + request.addParameter('TOKEN', 'abc123') + request.addParameter('username', 'sherlock') + + when: + def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') + + then: + msg.contains('Password: ***') + msg.contains('TOKEN: ***') + msg.contains('username: sherlock') + msg.contains('apiToken: visible') + !msg.contains('Password: secret') + !msg.contains('TOKEN: abc123') + } + + void "AuditorAwareLookup returns empty when no application context is provided"() { + given: + def lookup = new AuditorAwareLookup(null) + + expect: + !lookup.getCurrentAuditor().isPresent() + } + + void "logFullStackTraceIfEnabled emits the unfiltered trace when opt-in is enabled, and filterStackTrace then removes internal frames so the resolver log only sees the filtered trace"() { + given: "Captured System.err" + def originalErr = System.err + def baos = new ByteArrayOutputStream() + System.setErr(new PrintStream(baos, true)) + + and: "A resolver whose config opts in to full stack trace logging" + def config = Mock(Config) + config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> true + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false + config.getProperty('grails.logging.stackTraceFiltererClass', Class, _) >> + DefaultStackTraceFilterer + def grailsApp = Mock(GrailsApplication) + grailsApp.getConfig() >> config + def resolver = new GrailsExceptionResolver() + resolver.grailsApplication = grailsApp + + and: "An exception with a mix of internal (filterable) and application frames" + def exception = new RuntimeException('boom') + exception.stackTrace = [ + new StackTraceElement('java.lang.reflect.Method', 'invoke', 'Method.java', 580), + new StackTraceElement('com.example.MyController', 'show', 'MyController.groovy', 10), + ] as StackTraceElement[] + def request = new MockHttpServletRequest('GET', '/test') + + when: "The real resolveException ordering runs: log full trace, filter, then log with request context" + resolver.logFullStackTraceIfEnabled(exception) + resolver.filterStackTrace(exception) + resolver.logStackTrace(exception, request) + + then: "Both loggers emit" + System.err.flush() + def captured = baos.toString() + captured.contains('ERROR StackTrace ') + captured.contains('Full Stack Trace:') + captured.contains('o.g.web.errors.GrailsExceptionResolver') || + captured.contains('org.grails.web.errors.GrailsExceptionResolver') + + and: "The application frame appears in both log entries" + captured.count('com.example.MyController.show(MyController.groovy:10)') == 2 + + and: "The internal frame appears only once — in the unfiltered StackTrace entry, not in the filtered resolver entry" + captured.count('java.lang.reflect.Method.invoke(Method.java:580)') == 1 + + cleanup: + System.setErr(originalErr) + } +} From 55beb5e2589ce2e222d7102c50f4c81adb104880 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 1 Jul 2026 20:21:51 -0400 Subject: [PATCH 2/3] Address exception masking review feedback Ignore null entries while checking excluded request parameters so misconfigured lists do not interrupt request logging. Correct configuration metadata to document the actual mask emitted at runtime. Assisted-by: opencode:gpt-5.5 --- .../main/resources/META-INF/spring-configuration-metadata.json | 2 +- .../groovy/org/grails/web/errors/GrailsExceptionResolver.java | 2 +- .../org/grails/web/errors/GrailsExceptionResolverSpec.groovy | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 bb7e1a10c46..0ca996c8285 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 @@ -115,7 +115,7 @@ { "name": "grails.exceptionresolver.params.exclude", "type": "java.util.List", - "description": "List of parameter names to mask (replace with [*****]) in exception stack traces, typically used for password and creditCard. Parameter names are matched case-insensitively.", + "description": "List of parameter names to mask (replace with ***) in exception stack traces, typically used for password and creditCard. Parameter names are matched case-insensitively.", "defaultValue": [] }, { diff --git a/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java b/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java index 29c0cf83adf..2ae2c2bc17d 100644 --- a/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java +++ b/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java @@ -335,7 +335,7 @@ protected boolean isExcludedRequestParameter(String parameterName, List return false; } for (String excludedParameterName : excludedParameterNames) { - if (parameterName.equalsIgnoreCase(excludedParameterName)) { + if (excludedParameterName != null && parameterName.equalsIgnoreCase(excludedParameterName)) { return true; } } diff --git a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy index 261c73cb86c..8e7c3f5075f 100644 --- a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy +++ b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy @@ -248,7 +248,7 @@ class GrailsExceptionResolverSpec extends Specification { given: def config = Mock(Config) config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> true - config.getProperty('grails.exceptionresolver.params.exclude', List, _) >> ['password', 'token'] + config.getProperty('grails.exceptionresolver.params.exclude', List, _) >> [null, 'password', 'token'] config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false From fef6e8187650ec5158a15be801be36ebc8a52dcd Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 9 Jul 2026 15:34:38 -0400 Subject: [PATCH 3/3] Adjust exception masking tests for 7.0.x Assisted-by: opencode:gpt-5.5 --- .../errors/GrailsExceptionResolverSpec.groovy | 258 ------------------ 1 file changed, 258 deletions(-) diff --git a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy index 8e7c3f5075f..707c2e74fc1 100644 --- a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy +++ b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy @@ -22,7 +22,6 @@ import grails.config.Config import grails.core.GrailsApplication import grails.web.mapping.UrlMappingsHolder import grails.web.mapping.exceptions.UrlMappingException -import org.grails.exceptions.reporting.DefaultStackTraceFilterer import org.springframework.mock.web.MockHttpServletRequest import spock.lang.Specification @@ -46,204 +45,6 @@ class GrailsExceptionResolverSpec extends Specification { noExceptionThrown() params.isEmpty() } - void "logStackTrace emits only the resolver log"() { - given: "Captured System.err" - def originalErr = System.err - def baos = new ByteArrayOutputStream() - System.setErr(new PrintStream(baos, true)) - - and: "A resolver with no grailsApplication wired" - def resolver = new GrailsExceptionResolver() - def request = new MockHttpServletRequest('GET', '/test') - def exception = new RuntimeException('boom') - - when: - resolver.logStackTrace(exception, request) - - then: "Only the GrailsExceptionResolver logger emits; StackTrace logger is silent" - System.err.flush() - def captured = baos.toString() - captured.contains('o.g.web.errors.GrailsExceptionResolver') || - captured.contains('org.grails.web.errors.GrailsExceptionResolver') - !captured.contains('ERROR StackTrace ') - - cleanup: - System.setErr(originalErr) - } - - void "logFullStackTraceIfEnabled is a no-op when the opt-in property is unset"() { - given: "Captured System.err" - def originalErr = System.err - def baos = new ByteArrayOutputStream() - System.setErr(new PrintStream(baos, true)) - - and: "A resolver with no grailsApplication wired" - def resolver = new GrailsExceptionResolver() - def exception = new RuntimeException('boom') - - when: - resolver.logFullStackTraceIfEnabled(exception) - - then: "No StackTrace log entry is emitted" - System.err.flush() - !baos.toString().contains('ERROR StackTrace ') - - cleanup: - System.setErr(originalErr) - } - - void "getRequestLogMessage appends auditor when logAuditor is enabled and the lookup returns a value"() { - given: - def config = Mock(Config) - config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> true - config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false - config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false - def grailsApp = Mock(GrailsApplication) - grailsApp.getConfig() >> config - def resolver = new GrailsExceptionResolver() - resolver.grailsApplication = grailsApp - resolver.auditorAwareLookup = new AuditorAwareLookup(null) { - @Override - Optional getCurrentAuditor() { Optional.of('alice') } - } - def request = new MockHttpServletRequest('GET', '/test') - - when: - def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') - - then: - msg.contains('(user: alice)') - } - - void "getRequestLogMessage omits auditor when logAuditor is disabled"() { - given: - def config = Mock(Config) - config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false - config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false - def grailsApp = Mock(GrailsApplication) - grailsApp.getConfig() >> config - def resolver = new GrailsExceptionResolver() - resolver.grailsApplication = grailsApp - resolver.auditorAwareLookup = new AuditorAwareLookup(null) { - @Override - Optional getCurrentAuditor() { Optional.of('alice') } - } - def request = new MockHttpServletRequest('GET', '/test') - - when: - def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') - - then: - !msg.contains('(user:') - } - - void "getRequestLogMessage omits auditor when logAuditor is enabled but auditor is absent"() { - given: - def config = Mock(Config) - config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> true - config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false - config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false - def grailsApp = Mock(GrailsApplication) - grailsApp.getConfig() >> config - def resolver = new GrailsExceptionResolver() - resolver.grailsApplication = grailsApp - resolver.auditorAwareLookup = new AuditorAwareLookup(null) { - @Override - Optional getCurrentAuditor() { Optional.empty() } - } - def request = new MockHttpServletRequest('GET', '/test') - - when: - def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') - - then: - !msg.contains('(user:') - } - - void "getRequestLogMessage appends remote address when logRemoteAddr is enabled"() { - given: - def config = Mock(Config) - config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> true - config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false - config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false - def grailsApp = Mock(GrailsApplication) - grailsApp.getConfig() >> config - def resolver = new GrailsExceptionResolver() - resolver.grailsApplication = grailsApp - def request = new MockHttpServletRequest('GET', '/test') - request.remoteAddr = '198.51.100.42' - - when: - def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') - - then: - msg.contains('(ip: 198.51.100.42)') - !msg.contains('user:') - } - - void "getRequestLogMessage combines remote address and auditor into a single clause when both are enabled"() { - given: - def config = Mock(Config) - config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> true - config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> true - config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false - config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false - def grailsApp = Mock(GrailsApplication) - grailsApp.getConfig() >> config - def resolver = new GrailsExceptionResolver() - resolver.grailsApplication = grailsApp - resolver.auditorAwareLookup = new AuditorAwareLookup(null) { - @Override - Optional getCurrentAuditor() { Optional.of(42L) } - } - def request = new MockHttpServletRequest('GET', '/test') - request.remoteAddr = '198.51.100.42' - - when: - def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') - - then: - msg.contains('(ip: 198.51.100.42, user: 42)') - } - - void "subclasses can override resolveRemoteAddr to supply a custom IP extraction"() { - given: - def config = Mock(Config) - config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> true - config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false - config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false - def grailsApp = Mock(GrailsApplication) - grailsApp.getConfig() >> config - def resolver = new GrailsExceptionResolver() { - @Override - protected String resolveRemoteAddr(HttpServletRequest req) { - req.getHeader('X-Forwarded-For') ?: req.remoteAddr - } - } - resolver.grailsApplication = grailsApp - def request = new MockHttpServletRequest('GET', '/test') - request.remoteAddr = '10.0.0.1' - request.addHeader('X-Forwarded-For', '203.0.113.7') - - when: - def msg = resolver.getRequestLogMessage('RuntimeException', request, 'boom') - - then: - msg.contains('(ip: 203.0.113.7)') - } - void "getRequestLogMessage masks excluded request parameters case-insensitively"() { given: def config = Mock(Config) @@ -275,63 +76,4 @@ class GrailsExceptionResolverSpec extends Specification { !msg.contains('TOKEN: abc123') } - void "AuditorAwareLookup returns empty when no application context is provided"() { - given: - def lookup = new AuditorAwareLookup(null) - - expect: - !lookup.getCurrentAuditor().isPresent() - } - - void "logFullStackTraceIfEnabled emits the unfiltered trace when opt-in is enabled, and filterStackTrace then removes internal frames so the resolver log only sees the filtered trace"() { - given: "Captured System.err" - def originalErr = System.err - def baos = new ByteArrayOutputStream() - System.setErr(new PrintStream(baos, true)) - - and: "A resolver whose config opts in to full stack trace logging" - def config = Mock(Config) - config.getProperty('grails.exceptionresolver.logFullStackTrace', Boolean, false) >> true - config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false - config.getProperty('grails.exceptionresolver.logAuditor', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logRemoteAddr', Boolean, false) >> false - config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false - config.getProperty('grails.exceptionresolver.logRequestParameters', Boolean, _) >> false - config.getProperty('grails.logging.stackTraceFiltererClass', Class, _) >> - DefaultStackTraceFilterer - def grailsApp = Mock(GrailsApplication) - grailsApp.getConfig() >> config - def resolver = new GrailsExceptionResolver() - resolver.grailsApplication = grailsApp - - and: "An exception with a mix of internal (filterable) and application frames" - def exception = new RuntimeException('boom') - exception.stackTrace = [ - new StackTraceElement('java.lang.reflect.Method', 'invoke', 'Method.java', 580), - new StackTraceElement('com.example.MyController', 'show', 'MyController.groovy', 10), - ] as StackTraceElement[] - def request = new MockHttpServletRequest('GET', '/test') - - when: "The real resolveException ordering runs: log full trace, filter, then log with request context" - resolver.logFullStackTraceIfEnabled(exception) - resolver.filterStackTrace(exception) - resolver.logStackTrace(exception, request) - - then: "Both loggers emit" - System.err.flush() - def captured = baos.toString() - captured.contains('ERROR StackTrace ') - captured.contains('Full Stack Trace:') - captured.contains('o.g.web.errors.GrailsExceptionResolver') || - captured.contains('org.grails.web.errors.GrailsExceptionResolver') - - and: "The application frame appears in both log entries" - captured.count('com.example.MyController.show(MyController.groovy:10)') == 2 - - and: "The internal frame appears only once — in the unfiltered StackTrace entry, not in the filtered resolver entry" - captured.count('java.lang.reflect.Method.invoke(Method.java:580)') == 1 - - cleanup: - System.setErr(originalErr) - } }