The Security Audit That Found 4 Issues in One Weekend

작성자

카테고리:

← 피드로
DEV Community · Anand Rathnas · 2026-07-28 개발(SW)

Anand Rathnas

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.

TL;DR

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.

Issue 1: X-Frame-Options Nuking All Embeds

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

Enter fullscreen mode Exit fullscreen mode

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();
    }
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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

Issue 2: Stack Trace Leak via Type Binding

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

Enter fullscreen mode Exit fullscreen mode

Typed as int. Seems fine. But what happens when someone hits:

/embed/stats/abc123?days=foo

Enter fullscreen mode Exit fullscreen mode

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\""
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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.

Issue 3: CSP Header Conflicts

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.

Issue 4: Duplicate Security Configs

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();
    }
}

Enter fullscreen mode Exit fullscreen mode

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.

The Bigger Lesson

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:

  1. Understand what the defaults do — read the response headers your app is actually sending
  2. Take surgical control where defaults conflict with features
  3. Test the edges — the weird query parameters, the iframe scenarios, the multi-config interactions
  4. Don’t assume one security config covers everything — especially with multiple filter chains

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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다