# The Security Audit That Found 4 Issues in One Weekend

> Source: <https://dev.to/anand_rathnas_d5b608cc3de/the-security-audit-that-found-4-issues-in-one-weekend-1928>
> Published: 2026-07-28 04:15:25+00:00

This article was originally published on[Jo4 Blog].

I spent a weekend doing a security audit on jo4. I expected to find maybe one minor issue. I found four, and one of them was leaking stack traces to users.

Here's the full damage report.

Spring Security's default security headers are great — until they break your features. Blanket `X-Frame-Options: DENY`

kills embeddable widgets, unchecked query parameters leak stack traces, and default CSP can conflict with your custom headers. The fix isn't disabling security — it's taking surgical control.

Spring Security auto-injects `X-Frame-Options: DENY`

on every response. Every. Single. One.

For most endpoints, that's exactly what you want. You don't want someone embedding your login page in a malicious iframe. But jo4 has embed widgets — public stats dashboards that users can iframe into their own sites.

```
// What Spring Security was doing to ALL responses
X-Frame-Options: DENY
```

This meant every embed widget was broken. Browsers refused to render them in iframes. The feature existed, the code worked, but Spring Security was silently killing it at the HTTP header level.

The wrong fix would be to just disable `X-Frame-Options`

globally. That opens you up to clickjacking on every page.

The right fix: disable Spring's blanket header and delegate the decision to the controller that actually knows what should be embeddable.

```
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(h -> h.frameOptions(f -> f.disable()))
            // ... rest of security config
        ;
        return http.build();
    }
}
```

Then in `EmbedController`

, set the header dynamically based on whether the entity has `enablePublicStats`

turned on:

```
@GetMapping("/embed/stats/{shortCode}")
public ResponseEntity<EmbedResponse> getEmbedStats(
        @PathVariable String shortCode,
        HttpServletResponse response) {

    UrlEntity url = urlService.findByShortCode(shortCode);

    if (url != null && url.isEnablePublicStats()) {
        // Allow embedding anywhere
        response.setHeader("X-Frame-Options", "ALLOWALL");
    } else {
        // Lock it down
        response.setHeader("X-Frame-Options", "DENY");
    }

    // ... return embed data
}
```

Per-entity control. Public embeds work. Everything else stays locked. Spring Security isn't in the way anymore, but we didn't sacrifice anything.

The `EmbedController`

had a `days`

parameter for controlling the stats time range:

```
@GetMapping("/embed/stats/{shortCode}")
public ResponseEntity<EmbedResponse> getEmbedStats(
        @PathVariable String shortCode,
        @RequestParam int days) {  // <-- Problem
```

Typed as `int`

. Seems fine. But what happens when someone hits:

```
/embed/stats/abc123?days=foo
```

Spring's type binding can't convert `"foo"`

to `int`

. It throws a `TypeMismatchException`

. And by default, Spring returns a 400 with the full exception message — including internal class names, method signatures, and sometimes stack traces.

```
{
  "status": 400,
  "error": "Bad Request",
  "message": "Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: \"foo\""
}
```

That's an information leak. An attacker now knows you're running Spring, using Java, and can start probing for known vulnerabilities in those versions.

The fix is simple — accept a `String`

and parse it safely:

```
@GetMapping("/embed/stats/{shortCode}")
public ResponseEntity<EmbedResponse> getEmbedStats(
        @PathVariable String shortCode,
        @RequestParam(defaultValue = "30") String daysParam) {

    int days;
    try {
        days = Integer.parseInt(daysParam);
        if (days < 1 || days > 365) {
            days = 30;
        }
    } catch (NumberFormatException e) {
        days = 30; // Graceful default, no stack trace
    }

    // ... proceed with valid days value
}
```

No stack trace. No information leak. Bad input gets a sensible default. The user sees their stats for 30 days instead of an error page.

Spring Security has its own Content Security Policy defaults. I also had custom security headers configured. The result: two CSP headers on the same response, and browsers were picking the more restrictive one.

The fix was consolidating all security headers into a single `SecurityHeadersConfig`

instead of letting Spring inject defaults and then layering custom headers on top. One source of truth for CSP, not two fighting each other.

This one was embarrassing. I had two security configuration classes — `SecurityAuth0Config`

and `SecurityConfig`

— both configuring HTTP security. Both needed the `.headers(h -> h.frameOptions(f -> f.disable()))`

change.

I updated `SecurityConfig`

and thought I was done. Embeds were still broken in production. Spent 30 minutes debugging before I realized there was a second security config class that was still injecting `X-Frame-Options: DENY`

.

```
// SecurityAuth0Config.java — the one I forgot about
@Configuration
public class SecurityAuth0Config {
    @Bean
    public SecurityFilterChain auth0FilterChain(HttpSecurity http) throws Exception {
        http
            .headers(h -> h.frameOptions(f -> f.disable()))  // Need this here too!
            // ... auth0-specific config
        ;
        return http.build();
    }
}
```

The lesson: if you have multiple `SecurityFilterChain`

beans, every single one independently configures headers. Missing one means that filter chain still uses Spring's defaults.

Framework security defaults are a double-edged sword. They protect you from things you haven't thought about. But they also apply blanket policies that don't understand your specific features.

The answer isn't "disable Spring Security defaults." The answer is:

I found all four of these in one weekend because I finally sat down and `curl -v`

'd my own endpoints instead of just clicking through the UI. The UI looked fine. The headers told a different story.

**When was the last time you actually inspected the response headers your framework is sending?** You might be surprised what's in there.

*Building jo4.io - a URL shortener where the security headers actually make sense now.*
