rgoussu@goussu: ~/library/java/java-security
~/library/java/java-security cat web-security.md

Web security in Java apps

# The OWASP-top-10 concerns as they surface in Java stacks, and which parts Spring, Quarkus and Micronaut handle for you.

Conceptsaved 2026-08-09 #java#security#web#owasp#spring#quarkus#micronaut

Overview

Most web vulnerabilities in Java apps are not exotic: they are the OWASP top-10 classics surfacing through JDBC, template engines, Jackson and servlet plumbing. The frameworks absorb a lot — Spring Security in particular ships secure-by-default CSRF, headers and session handling — but each protection has a boundary where responsibility passes back to you: native queries, unsafe template modes, permissive CORS, polymorphic deserialization. This note walks the top-10 concerns and marks, per stack, what is handled and what is your job. The attack theory itself lives in AppSec fundamentals.

Key points

  • Injection: parameterized statements everywhere — PreparedStatement, JPA/Hibernate bind parameters, jOOQ's DSL. The ORM protects you only until you concatenate into JPQL/native queries or dynamic ORDER BY columns.
  • XSS: rely on the template engine's contextual auto-escaping — Thymeleaf (th:text vs the dangerous th:utext), Quarkus Qute (escapes by default in HTML, {value.raw} opts out). JSON APIs shift XSS to the consuming frontend, but set X-Content-Type-Options: nosniff and correct Content-Type anyway.
  • CSRF: Spring Security enables token protection by default; disable it only for stateless bearer-token APIs (no cookie = no CSRF vector). Quarkus has quarkus-rest-csrf; Micronaut ships micronaut-security-csrf — both opt-in.
  • CORS: never * with credentials. Spring: CorsConfigurationSource wired into the filter chain; Quarkus: quarkus.http.cors.* properties; Micronaut: micronaut.server.cors.*. An allowlist of exact origins, not a reflex allow-all.
  • Security headers: Spring Security writes sensible defaults (HSTS on HTTPS, X-Content-Type-Options, cache control, frame options) but no CSP — add that yourself. Quarkus and Micronaut set little by default: configure headers explicitly (quarkus.http.header.*, Micronaut server filters).
  • Sessions: servlet containers regenerate the session ID on login when the framework drives authentication (Spring Security's session-fixation protection defaults to changeSessionId); cookies need HttpOnly, Secure, and a considered SameSite.
  • Deserialization: never deserialize untrusted data with Java native serialization; if legacy forces it, use JEP 290 serialization filters (ObjectInputFilter/jdk.serialFilter). In Jackson, avoid enabling polymorphic default typing — gadget-chain RCE lives there.
  • Path traversal & uploads: canonicalize and containment-check every user-supplied path; treat uploaded filenames as hostile display data, store under generated names.
  • SSRF: any URL fetched on behalf of a user needs an allowlist and a metadata-endpoint block; no framework does this for you.

Details

Injection — where the ORM's protection ends

PreparedStatement and JPA parameter binding (:name, ?1) make classic SQLi hard to write. The residual risk is structural SQL that cannot be bound: table/column names, ORDER BY clauses, IN-list construction via string building, and entityManager.createNativeQuery(concatenated). Map user input to a fixed set of known column names rather than sanitising. Same rule for the other injections: LDAP filters, OS commands (ProcessBuilder with argument lists, never sh -c + concat), and NoSQL query documents built from raw JSON.

XSS and output encoding

Server-rendered HTML: Thymeleaf escapes th:text/${...} output contextually; th:utext and inline JavaScript blocks are the escape hatches to audit. Qute escapes expressions in HTML/XML templates by default; raw opts out. JSP without <c:out> (raw EL ${} in template text) is the legacy trap. For rich-text features, sanitise on input with the OWASP Java HTML Sanitizer rather than trying to encode on output. A CSP is the defence-in-depth backstop for whatever slips through.

CSRF and statelessness

CSRF needs an ambient credential — a cookie. Session-cookie apps must keep CSRF tokens on (Spring's CsrfFilter with the cookie/header handshake for SPAs: CookieCsrfTokenRepository). A pure bearer-token API (Authorization header, no cookies) can and should disable it: http.csrf(csrf -> csrf.disable()) — the single legitimate use of that much-cargo-culted line. Beware hybrid apps: an OIDC-login session cookie plus "stateless" API endpoints on the same host is not CSRF-immune.

Framework responsibility table

Concern Spring Quarkus Micronaut
CSRF on by default (Spring Security) quarkus-rest-csrf opt-in micronaut-security-csrf opt-in
CORS filter-chain config quarkus.http.cors.* micronaut.server.cors.*
Headers (HSTS etc.) defaults on, CSP manual manual (quarkus.http.header.*) manual (filters/config)
Session fixation auto (changeSessionId) container/OIDC handled session module handles regeneration
Auth rate limiting / lockout manual/bucket4j manual manual

Deserialization in depth

Java native serialization over any untrusted boundary (RMI, cached blobs, cookies) is an RCE machine — ysoserial gadget chains need only a vulnerable classpath. Mitigate with JEP 290 filters (allowlist patterns via ObjectInputFilter.Config) or, better, replace the format. Jackson is safe by default; danger arrives with activateDefaultTyping/@JsonTypeInfo over broad base types (Object, Serializable), which reintroduces attacker-chosen classes. Keep polymorphism closed: @JsonSubTypes with an explicit, finite list.

Examples

// Spring Security 6 — stateless API: CSRF off, explicit headers incl. CSP
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    return http
        .csrf(csrf -> csrf.disable())              // bearer-token only, no cookies
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .headers(h -> h.contentSecurityPolicy(csp ->
            csp.policyDirectives("default-src 'none'; frame-ancestors 'none'")))
        .authorizeHttpRequests(a -> a.anyRequest().authenticated())
        .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()))
        .build();
}
// Path containment check — canonicalize before use
Path base = Path.of("/var/uploads").toRealPath();
Path target = base.resolve(userSupplied).normalize();
if (!target.startsWith(base)) throw new SecurityException("traversal attempt");

Related