Skip to content

Commit 0e6b00b

Browse files
committed
Make defer-outer-filter-packages configurable
Introduce a configurable web-captor.defer-outer-filter-packages property (defaulting to org.springframework.security.) so exceptions from configured package prefixes are re-thrown to outer servlet filters instead of rendered as 500s. Pass the configured list from WebCaptorProperties through AppConfig into UnhandledExceptionResponseFilter, replace reflection-based Spring Security checks with a generic cause-chain prefix check (shouldDeferToOuterFilter), and update logging. Add README documentation for the new property and comprehensive tests (unit updates, new integration test and test fixture com.acme.security.CustomDenied) to verify configurable deferral behavior.
1 parent 190d711 commit 0e6b00b

7 files changed

Lines changed: 351 additions & 371 deletions

File tree

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ All properties are optional. The library works out of the box with sensible defa
8080
| Property | Type | Default | Description |
8181
|---|---|---|---|
8282
| `web-captor.enabled` | `boolean` | `true` | Enable or disable all HTTP capturing |
83+
| `web-captor.defer-outer-filter-packages` | `List<String>` | `[org.springframework.security.]` | Package prefixes whose exceptions are re-thrown for an outer servlet filter to translate. See [Defer Outer-Filter Packages](#defer-outer-filter-packages-web-captordefer-outer-filter-packages). |
8384

8485
### Event Details (`web-captor.event-details.*`)
8586

@@ -126,6 +127,53 @@ web-captor:
126127
method: GET,POST
127128
```
128129
130+
### Defer Outer-Filter Packages (`web-captor.defer-outer-filter-packages`)
131+
132+
When an exception is thrown from a controller, the captor's `UnhandledExceptionResponseFilter`
133+
catches it as a safety net and renders a JSON 500 body. That's the right behavior for *truly*
134+
unhandled exceptions — but for exceptions that a servlet filter outside the captor is supposed
135+
to translate (the classic example is Spring Security's `ExceptionTranslationFilter` calling
136+
`sendError(403)` on `AccessDeniedException`), the captor must re-throw so the outer filter can
137+
do its job. Otherwise you get 500s where you expected 401/403.
138+
139+
`web-captor.defer-outer-filter-packages` is the list of package-name prefixes whose exceptions
140+
get re-thrown instead of rendered. The captor walks the exception's cause chain and defers if
141+
any frame's class name starts with one of these prefixes — subclasses are matched automatically,
142+
so you list package roots, not individual classes.
143+
144+
**Default:**
145+
146+
```yaml
147+
web-captor:
148+
defer-outer-filter-packages:
149+
- org.springframework.security.
150+
```
151+
152+
This is the only namespace shipped by default because Spring Security is the only widely-used
153+
framework that translates exceptions in an outer servlet filter (rather than via
154+
`@ControllerAdvice` / `HandlerExceptionResolver`, both of which the captor handles automatically).
155+
156+
**Extending for a custom framework:**
157+
158+
```yaml
159+
web-captor:
160+
defer-outer-filter-packages:
161+
- org.springframework.security. # keep this
162+
- com.acme.security. # your framework
163+
- org.example.auth. # another one
164+
```
165+
166+
Any `RuntimeException` thrown from those packages — or any exception whose cause chain contains
167+
one — will be re-thrown by the captor so your outer filter can translate it. The library never
168+
needs to know about specific exception classes; the package prefix is enough.
169+
170+
**Disabling deferral entirely** (rare — captor will catch and 500 even security exceptions):
171+
172+
```yaml
173+
web-captor:
174+
defer-outer-filter-packages: []
175+
```
176+
129177
---
130178

131179
## Captured Data

spring-web-captor/src/main/java/com/davidrandoll/spring_web_captor/config/AppConfig.java

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -142,19 +142,12 @@ public ErrorProperties errorProperties() {
142142
return new ErrorProperties();
143143
}
144144

145-
/**
146-
* Registered with an order that places it OUTSIDE Spring Security's filter chain
147-
* (DEFAULT_FILTER_ORDER = -100). HIGHEST_PRECEDENCE + 2 keeps it just inside the existing
148-
* HttpResponseEventPublisher (HIGHEST_PRECEDENCE + 1) so the captor still observes the
149-
* response we write for truly-unhandled exceptions, but well before Spring Security so
150-
* its ExceptionTranslationFilter handles its own exceptions first.
151-
*/
152145
@Bean("unhandledExceptionResponseFilter")
153146
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
154147
@ConditionalOnMissingBean(name = "unhandledExceptionResponseFilter", ignored = UnhandledExceptionResponseFilter.class)
155148
@Conditional(IsWebCaptorEnabled.class)
156-
public UnhandledExceptionResponseFilter unhandledExceptionResponseFilter(ObjectMapper mapper, ErrorProperties properties) {
157-
return new UnhandledExceptionResponseFilter(mapper, properties);
149+
public UnhandledExceptionResponseFilter unhandledExceptionResponseFilter(ObjectMapper mapper, ErrorProperties errorProperties, WebCaptorProperties webCaptorProperties) {
150+
return new UnhandledExceptionResponseFilter(mapper, errorProperties, webCaptorProperties.getDeferOuterFilterPackages());
158151
}
159152

160153
@Bean("excludedPathPublishCondition")

spring-web-captor/src/main/java/com/davidrandoll/spring_web_captor/properties/WebCaptorProperties.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.ArrayList;
99
import java.util.List;
1010

11+
1112
@Data
1213
@Configuration
1314
@ConfigurationProperties(prefix = "web-captor")
@@ -22,6 +23,30 @@ public class WebCaptorProperties {
2223

2324
private List<ExcludedRequest> excludedEndpoints = new ArrayList<>();
2425

26+
/**
27+
* Package-name prefixes whose exceptions should be re-thrown from
28+
* {@code UnhandledExceptionResponseFilter} instead of being rendered as a 500. Used when an
29+
* outer servlet filter (one that runs <em>outside</em> our captor filters in the chain) is
30+
* expected to translate the exception itself — e.g. Spring Security's
31+
* {@code ExceptionTranslationFilter} calling {@code sendError(403)} for
32+
* {@code AccessDeniedException}.
33+
*
34+
* <p>The default list covers Spring Security. To extend for another framework with the same
35+
* translate-in-an-outer-filter pattern, replace or augment the list via configuration:
36+
*
37+
* <pre>{@code
38+
* web-captor:
39+
* defer-outer-filter-packages:
40+
* - org.springframework.security.
41+
* - com.acme.security.
42+
* }</pre>
43+
*
44+
* <p>The captor walks the exception's cause chain (depth-limited, self-reference-safe) and
45+
* defers if any frame's class name starts with one of these prefixes. Subclasses are matched
46+
* automatically — no per-class list to maintain.
47+
*/
48+
private List<String> deferOuterFilterPackages = new ArrayList<>(List.of("org.springframework.security."));
49+
2550
@Data
2651
public static class AdditionalDetails {
2752
private boolean duration = true;

spring-web-captor/src/main/java/com/davidrandoll/spring_web_captor/publisher/response/UnhandledExceptionResponseFilter.java

Lines changed: 34 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -15,69 +15,44 @@
1515

1616
import java.io.IOException;
1717
import java.util.HashMap;
18+
import java.util.List;
1819

1920
/**
20-
* Last-resort fallback that renders a JSON 500 body for exceptions that escape
21-
* <em>every</em> other layer of the request pipeline.
21+
* Safety-net renderer for runtime exceptions that escape <em>everything</em> — no
22+
* {@code HandlerExceptionResolver} claimed them inside the dispatcher, no servlet filter
23+
* outside the dispatcher (e.g. Spring Security's {@code ExceptionTranslationFilter})
24+
* translated them via {@code sendError}, and there is no Spring Boot {@code /error}
25+
* endpoint that would render them on an error dispatch.
2226
*
23-
* <p>Why we still need a security-exception skip here: depending on how Spring Security 6's
24-
* {@code CompositeFilterChainProxy} composes the host application's servlet filters, this
25-
* filter can end up <em>inside</em> {@code ExceptionTranslationFilter}'s call to
26-
* {@code chain.doFilter}. In that configuration, an {@code AccessDeniedException} bubbling up
27-
* from the dispatcher reaches us <em>before</em> {@code ExceptionTranslationFilter} can
28-
* translate it to a 403/401. We detect that situation structurally — by checking whether the
29-
* escaped exception (or any cause in its chain) is a Spring Security exception — and
30-
* <em>re-throw</em> it so the outer security filter can do its job.
31-
*
32-
* <p>Detection uses a <strong>package prefix</strong> (any class in
33-
* {@code org.springframework.security.*}), not a hard-coded class list, so new Spring Security
34-
* exception subclasses in future versions are handled automatically — no maintenance required.
35-
*
36-
* <p>For every other unhandled {@code RuntimeException} we render the captor's standard 500 body.
37-
* If a response is already committed when an exception escapes, we re-raise rather than try to
38-
* write a new body — the client gets whatever the container's error handling produces (never
39-
* an empty response).
27+
* <p>Exceptions whose class lives in any package listed in
28+
* {@code web-captor.defer-outer-filter-packages} (default: Spring Security) are re-thrown
29+
* instead of being rendered, so the outer translator can run. To extend for another framework
30+
* with the same outer-filter-translates-via-sendError pattern, just add its root package to
31+
* the property — no code change to the library needed.
4032
*/
4133
@Slf4j
4234
@RequiredArgsConstructor
4335
public class UnhandledExceptionResponseFilter extends OncePerRequestFilter {
4436

45-
/**
46-
* Detection is reflection-based at class init so spring-web-captor still works in apps
47-
* that don't have spring-security on the classpath — these references stay null and the
48-
* security-skip is simply inert.
49-
*/
50-
private static final Class<?> ACCESS_DENIED_CLASS = loadClass("org.springframework.security.access.AccessDeniedException");
51-
private static final Class<?> AUTHENTICATION_EXCEPTION_CLASS = loadClass("org.springframework.security.core.AuthenticationException");
52-
private static final String SECURITY_PACKAGE_PREFIX = "org.springframework.security.";
53-
54-
private static Class<?> loadClass(String name) {
55-
try {
56-
return Class.forName(name);
57-
} catch (ClassNotFoundException e) {
58-
return null;
59-
}
60-
}
61-
6237
private final ObjectMapper mapper;
6338
private final ErrorProperties errorProperties;
39+
/**
40+
* Package-name prefixes whose exceptions are deferred to an outer filter.
41+
* Backed by {@code web-captor.defer-outer-filter-packages}; default is
42+
* {@code [org.springframework.security.]}.
43+
*/
44+
private final List<String> deferOuterFilterPackages;
6445

6546
@Override
6647
protected void initFilterBean() {
67-
log.info("UnhandledExceptionResponseFilter initialized (safety-net for truly-unhandled exceptions)");
48+
log.info("UnhandledExceptionResponseFilter initialized; defer packages: {}", deferOuterFilterPackages);
6849
}
6950

7051
@Override
7152
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain) throws IOException, ServletException {
7253
try {
7354
chain.doFilter(request, response);
74-
if (log.isDebugEnabled()) {
75-
log.debug("UnhandledExceptionResponseFilter pass-through: status={} committed={}",
76-
response.getStatus(), response.isCommitted());
77-
}
7855
} catch (ServletException ex) {
79-
// FrameworkServlet wraps anything that escapes the dispatcher (incl. unhandled
80-
// RuntimeExceptions) in a ServletException. Unwrap to the real cause for the body.
8156
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
8257
handleEscapedException(request, response, cause, ex);
8358
} catch (RuntimeException ex) {
@@ -86,43 +61,40 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
8661
}
8762

8863
private void handleEscapedException(HttpServletRequest request, HttpServletResponse response, Throwable cause, Exception originalToRethrow) throws IOException, ServletException {
89-
// 1. Defer to Spring Security: re-throw so its ExceptionTranslationFilter
90-
// (which wraps this filter in some configurations) can translate to 401/403.
91-
if (shouldDeferToSecurityFilter(cause)) {
92-
log.debug("Deferring Spring Security exception {} so ExceptionTranslationFilter can translate it",
93-
cause.getClass().getName());
64+
if (shouldDeferToOuterFilter(cause, deferOuterFilterPackages)) {
65+
log.debug("Deferring {} so an outer translator can handle it", cause.getClass().getName());
9466
rethrow(originalToRethrow);
95-
return; // unreachable
67+
return;
9668
}
97-
// 2. Response already committed: we can't write a new body. Re-raise.
9869
if (response.isCommitted()) {
9970
log.warn("Response already committed when {} escaped; re-raising for container error handling",
10071
cause.getClass().getName());
10172
rethrow(originalToRethrow);
102-
return; // unreachable
73+
return;
10374
}
104-
// 3. Truly unhandled — render the standard 500 body so the client gets a real response.
10575
log.error("Unhandled exception bubbled to outer captor filter — rendering 500. Exception type: {}",
10676
cause.getClass().getName(), cause);
10777
writeErrorBody(request, response, cause);
10878
}
10979

11080
/**
111-
* Walks the cause chain looking for any Spring Security exception type. Uses an
112-
* {@code isInstance} check against the loaded {@code AccessDeniedException} /
113-
* {@code AuthenticationException} classes (covers all current and future subclasses) and
114-
* a package-prefix fallback (covers any other security exception that doesn't extend those
115-
* two roots — e.g. internal token-validation exceptions in oauth2 resource-server).
81+
* Walks the exception's cause chain looking for any frame whose class name starts with one
82+
* of the configured defer-prefixes. Depth-limited and self-reference-safe. Public so it can
83+
* be used in tests / custom filters.
11684
*/
117-
public static boolean shouldDeferToSecurityFilter(Throwable t) {
85+
public static boolean shouldDeferToOuterFilter(Throwable t, List<String> packagePrefixes) {
86+
if (packagePrefixes == null || packagePrefixes.isEmpty()) return false;
11887
Throwable cursor = t;
11988
int depth = 0;
12089
while (cursor != null && depth++ < 16) {
121-
if (ACCESS_DENIED_CLASS != null && ACCESS_DENIED_CLASS.isInstance(cursor)) return true;
122-
if (AUTHENTICATION_EXCEPTION_CLASS != null && AUTHENTICATION_EXCEPTION_CLASS.isInstance(cursor)) return true;
123-
if (cursor.getClass().getName().startsWith(SECURITY_PACKAGE_PREFIX)) return true;
90+
String className = cursor.getClass().getName();
91+
for (String prefix : packagePrefixes) {
92+
if (prefix != null && !prefix.isEmpty() && className.startsWith(prefix)) {
93+
return true;
94+
}
95+
}
12496
Throwable next = cursor.getCause();
125-
if (next == cursor) break; // self-cause guard
97+
if (next == cursor) break;
12698
cursor = next;
12799
}
128100
return false;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.acme.security;
2+
3+
/**
4+
* Test fixture only — pretends to be a security exception from a custom framework outside the
5+
* Spring Security namespace, used to verify {@code web-captor.defer-outer-filter-packages} can
6+
* be extended to cover arbitrary packages without library code changes.
7+
*/
8+
public class CustomDenied extends RuntimeException {
9+
public CustomDenied(String msg) {
10+
super(msg);
11+
}
12+
}

0 commit comments

Comments
 (0)