{"slug": "the-security-audit-that-found-4-issues-in-one-weekend", "title": "The Security Audit That Found 4 Issues in One Weekend", "summary": "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.", "body_md": "This article was originally published on[Jo4 Blog].\n\nI 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.\n\nHere's the full damage report.\n\nSpring Security's default security headers are great — until they break your features. Blanket `X-Frame-Options: DENY`\n\nkills 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.\n\nSpring Security auto-injects `X-Frame-Options: DENY`\n\non every response. Every. Single. One.\n\nFor 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.\n\n```\n// What Spring Security was doing to ALL responses\nX-Frame-Options: DENY\n```\n\nThis 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.\n\nThe wrong fix would be to just disable `X-Frame-Options`\n\nglobally. That opens you up to clickjacking on every page.\n\nThe right fix: disable Spring's blanket header and delegate the decision to the controller that actually knows what should be embeddable.\n\n```\n@Configuration\npublic class SecurityConfig {\n    @Bean\n    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {\n        http\n            .headers(h -> h.frameOptions(f -> f.disable()))\n            // ... rest of security config\n        ;\n        return http.build();\n    }\n}\n```\n\nThen in `EmbedController`\n\n, set the header dynamically based on whether the entity has `enablePublicStats`\n\nturned on:\n\n```\n@GetMapping(\"/embed/stats/{shortCode}\")\npublic ResponseEntity<EmbedResponse> getEmbedStats(\n        @PathVariable String shortCode,\n        HttpServletResponse response) {\n\n    UrlEntity url = urlService.findByShortCode(shortCode);\n\n    if (url != null && url.isEnablePublicStats()) {\n        // Allow embedding anywhere\n        response.setHeader(\"X-Frame-Options\", \"ALLOWALL\");\n    } else {\n        // Lock it down\n        response.setHeader(\"X-Frame-Options\", \"DENY\");\n    }\n\n    // ... return embed data\n}\n```\n\nPer-entity control. Public embeds work. Everything else stays locked. Spring Security isn't in the way anymore, but we didn't sacrifice anything.\n\nThe `EmbedController`\n\nhad a `days`\n\nparameter for controlling the stats time range:\n\n```\n@GetMapping(\"/embed/stats/{shortCode}\")\npublic ResponseEntity<EmbedResponse> getEmbedStats(\n        @PathVariable String shortCode,\n        @RequestParam int days) {  // <-- Problem\n```\n\nTyped as `int`\n\n. Seems fine. But what happens when someone hits:\n\n```\n/embed/stats/abc123?days=foo\n```\n\nSpring's type binding can't convert `\"foo\"`\n\nto `int`\n\n. It throws a `TypeMismatchException`\n\n. And by default, Spring returns a 400 with the full exception message — including internal class names, method signatures, and sometimes stack traces.\n\n```\n{\n  \"status\": 400,\n  \"error\": \"Bad Request\",\n  \"message\": \"Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: \\\"foo\\\"\"\n}\n```\n\nThat's an information leak. An attacker now knows you're running Spring, using Java, and can start probing for known vulnerabilities in those versions.\n\nThe fix is simple — accept a `String`\n\nand parse it safely:\n\n```\n@GetMapping(\"/embed/stats/{shortCode}\")\npublic ResponseEntity<EmbedResponse> getEmbedStats(\n        @PathVariable String shortCode,\n        @RequestParam(defaultValue = \"30\") String daysParam) {\n\n    int days;\n    try {\n        days = Integer.parseInt(daysParam);\n        if (days < 1 || days > 365) {\n            days = 30;\n        }\n    } catch (NumberFormatException e) {\n        days = 30; // Graceful default, no stack trace\n    }\n\n    // ... proceed with valid days value\n}\n```\n\nNo stack trace. No information leak. Bad input gets a sensible default. The user sees their stats for 30 days instead of an error page.\n\nSpring 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.\n\nThe fix was consolidating all security headers into a single `SecurityHeadersConfig`\n\ninstead of letting Spring inject defaults and then layering custom headers on top. One source of truth for CSP, not two fighting each other.\n\nThis one was embarrassing. I had two security configuration classes — `SecurityAuth0Config`\n\nand `SecurityConfig`\n\n— both configuring HTTP security. Both needed the `.headers(h -> h.frameOptions(f -> f.disable()))`\n\nchange.\n\nI updated `SecurityConfig`\n\nand 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`\n\n.\n\n```\n// SecurityAuth0Config.java — the one I forgot about\n@Configuration\npublic class SecurityAuth0Config {\n    @Bean\n    public SecurityFilterChain auth0FilterChain(HttpSecurity http) throws Exception {\n        http\n            .headers(h -> h.frameOptions(f -> f.disable()))  // Need this here too!\n            // ... auth0-specific config\n        ;\n        return http.build();\n    }\n}\n```\n\nThe lesson: if you have multiple `SecurityFilterChain`\n\nbeans, every single one independently configures headers. Missing one means that filter chain still uses Spring's defaults.\n\nFramework 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.\n\nThe answer isn't \"disable Spring Security defaults.\" The answer is:\n\nI found all four of these in one weekend because I finally sat down and `curl -v`\n\n'd my own endpoints instead of just clicking through the UI. The UI looked fine. The headers told a different story.\n\n**When was the last time you actually inspected the response headers your framework is sending?** You might be surprised what's in there.\n\n*Building jo4.io - a URL shortener where the security headers actually make sense now.*", "url": "https://wpnews.pro/news/the-security-audit-that-found-4-issues-in-one-weekend", "canonical_source": "https://dev.to/anand_rathnas_d5b608cc3de/the-security-audit-that-found-4-issues-in-one-weekend-1928", "published_at": "2026-07-28 04:15:25+00:00", "updated_at": "2026-07-28 05:03:37.289134+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["jo4", "Spring Security"], "alternates": {"html": "https://wpnews.pro/news/the-security-audit-that-found-4-issues-in-one-weekend", "markdown": "https://wpnews.pro/news/the-security-audit-that-found-4-issues-in-one-weekend.md", "text": "https://wpnews.pro/news/the-security-audit-that-found-4-issues-in-one-weekend.txt", "jsonld": "https://wpnews.pro/news/the-security-audit-that-found-4-issues-in-one-weekend.jsonld"}}