cd /news/developer-tools/the-security-audit-that-found-4-issu… · home topics developer-tools article
[ARTICLE · art-76431] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

The Security Audit That Found 4 Issues in One Weekend

A developer found four security issues during a weekend audit of the jo4 URL shortener, including Spring Security's default X-Frame-Options header breaking embeddable widgets and a stack trace leak via unchecked query parameters. The developer fixed the issues by taking surgical control of security headers and parsing parameters safely.

read4 min views1 publishedJul 28, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @jo4 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-security-audit-t…] indexed:0 read:4min 2026-07-28 ·