Skip to content

Secure coding

Notes on the code-level controls that come up repeatedly in review of Java and Spring services. Each section states the failure it prevents; the code is illustrative rather than copy-ready.

Data that reaches an HTML page must be encoded for the context it lands in — HTML body, HTML attribute, JavaScript string, URL parameter and CSS all have different escaping rules, and a single “sanitise” function that does not know which of them it is writing into will get one of them wrong. Encode at the point of output, not on the way into storage: the same value may be rendered into more than one context, and encoding on input corrupts the stored data.

Use the OWASP Java Encoder on the server:

import org.owasp.encoder.Encode;
String safeBody = Encode.forHtml(value);
String safeAttribute = Encode.forHtmlAttribute(value);
String safeScriptString = Encode.forJavaScript(value);

In a Thymeleaf template, th:text encodes and th:utext does not. Reaching for th:utext to make markup render is how stored XSS gets shipped:

<!-- Unsafe: renders attacker-supplied markup -->
<span th:utext="${filter}"></span>
<!-- Safe -->
<span th:text="${filter}"></span>

Where a page genuinely has to render user-supplied HTML, sanitise it with a dedicated sanitiser rather than an encoder — DOMPurify on the client, or the OWASP Java HTML Sanitizer on the server. Client-side code that builds DOM nodes should set textContent rather than assembling an innerHTML string; DOM-based XSS involves no server round trip, so no server-side control can catch it.

An uncaught exception should produce a short, generic message. A stack trace on the response tells an attacker the framework, the library versions and often the file layout.

In Spring Boot, disable the Whitelabel page and supply a controller that renders only the attributes you choose:

debug=false
server.error.whitelabel.enabled=false
server.error.include-stacktrace=never
server.error.include-message=never
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.ServletWebRequest;
import java.util.Map;
@Controller
public class CustomErrorController implements ErrorController {
private final ErrorAttributes errorAttributes;
public CustomErrorController(ErrorAttributes errorAttributes) {
this.errorAttributes = errorAttributes;
}
@RequestMapping("${server.error.path:/error}")
String error(HttpServletRequest request, Model model) {
Map<String, Object> attributes = errorAttributes.getErrorAttributes(
new ServletWebRequest(request), ErrorAttributeOptions.defaults());
model.addAttribute("status", attributes.get("status"));
model.addAttribute("timestamp", attributes.get("timestamp"));
return "error";
}
}

ErrorAttributeOptions.defaults() includes neither the stack trace nor the exception message. The error template should render the status and a correlation identifier and nothing else — the detail belongs in the server log, indexed by that identifier.

Note that ErrorController moved to org.springframework.boot.web.servlet.error in Spring Boot 2.0, getErrorPath() was removed in 2.5 in favour of the server.error.path property, and the javax.servlet packages became jakarta.servlet in Spring Boot 3.0. Samples predating those changes will not compile.

Do not let a request parameter turn debugging on. An attacker who can set it gets the debug output written to logs they may be able to read, and a detailed picture of the system either way. Debug verbosity belongs in configuration that only the deployment can set:

package com.example.settings;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
@Data
public class DebugSettings {
@Value("${debug.enabled:false}")
private Boolean enabled;
}

Beyond that:

  • Keep separate logging levels for test and production environments, and check the production value as part of the deployment — a service that ships to production at DEBUG will log request bodies, configuration and sometimes credentials. In Spring Boot the property is logging.level.<logger>; there is no spring.debug.level.
  • Log internal identifiers, not email addresses, names or anything else that identifies a person. A log store rarely has the access controls the database has.
  • Log too little and an attacker’s password changes and profile edits pass unnoticed; log everything and the entries that matter are buried. Log authentication outcomes, authorisation failures and changes to credentials or permissions, with enough context to trace the actor.
  • Use a distinct logger for debug output so it can be raised and lowered without touching the audit trail.

Set HttpOnly on the session cookie so that script cannot read it. If the application is compromised by XSS, HttpOnly is what keeps the session identifier out of the attacker’s hands:

servletContext.getSessionCookieConfig().setHttpOnly(true);
servletContext.getSessionCookieConfig().setSecure(true);

Explicitly setting setHttpOnly(false) — which appears in old samples — makes the cookie readable by any script on the page and should never survive review. Add SameSite=Lax or Strict as the CSRF countermeasure that does not depend on a token.

Encryption does not prevent a deserialization attack. Wrapping a CipherInputStream around an ObjectInputStream still hands a byte stream to readObject(), which instantiates whatever classes the payload names before any application code sees the result. An InvalidClassException raised by a corrupt stream is a decoding failure, not a security control.

Two related pieces of old advice are worth naming because they still circulate. A transformation string of "Blowfish" alone names no mode and no padding, so the provider substitutes its default — ECB in SunJCE — which encrypts identical plaintext blocks to identical ciphertext blocks and leaks the structure of the object graph. Blowfish’s 64-bit block is also too small for modern data volumes. Where a serialized object genuinely must be protected in transit or at rest, use AES/GCM/NoPadding with a 256-bit key and a unique nonce per message, which authenticates the ciphertext as well as concealing it. That is confidentiality and integrity; it is still not input validation.

The control that stops a gadget chain is an allow-list of the classes the stream may resolve. There are three ways to apply one, in roughly descending order of preference.

A JDK serialization filter (java.io.ObjectInputFilter, available since Java 9) applies without changing the deserialization call sites. Set it per stream, or process-wide:

ObjectInputFilter filter =
ObjectInputFilter.Config.createFilter("com.example.dto.*;java.base/*;!*");
ObjectInputFilter.Config.setSerialFilter(filter);

The same pattern can be supplied at launch as -Djdk.serialFilter=.... The trailing !* is what makes it an allow-list: everything not named is rejected.

Apache Commons IO’s ValidatingObjectInputStream takes the allow-list at the call site. Use the builder — the public constructors are deprecated:

try (InputStream in = Files.newInputStream(path);
ValidatingObjectInputStream ois = ValidatingObjectInputStream.builder()
.accept(UserInfoDto.class)
.setInputStream(in)
.get()) {
return (UserInfoDto) ois.readObject();
}

A subclass overriding resolveClass is the portable fallback where neither of the above is available:

public class SingleTypeObjectInputStream extends ObjectInputStream {
public SingleTypeObjectInputStream(InputStream in) throws IOException {
super(in);
}
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
if (!desc.getName().equals(EmployeeRequest.class.getName())) {
throw new InvalidClassException("Unauthorized deserialization attempt",
desc.getName());
}
return super.resolveClass(desc);
}
}

The best option remains not to deserialize attacker-influenced Java object streams at all. A data format with no code-execution semantics — JSON or Protocol Buffers bound to a declared type — removes the class of bug rather than filtering it.

PKCS #12 is the archive format used to store cryptographic objects in a single file, most often to bundle a private key with its X.509 certificate or to carry a complete chain of trust. The file may be encrypted and signed, and so may the individual containers inside it, called SafeBags; predefined SafeBag types hold certificates, private keys and CRLs, and one general type holds anything else. A PKCS #12 file is protected only by its password, so treat it as a secret in its own right: keep it out of the source tree and out of the container image, and load it from the same secret store as the database credentials.

Fetch libraries from official repositories over TLS and prefer signed artefacts. Run a software composition analysis tool as part of the build so a dependency with a published vulnerability fails the build rather than reaching production — OWASP Dependency-Check has Gradle and Maven plugins and can be configured to fail on a CVSS score above a chosen threshold. Choose that threshold deliberately: scores are defined in the CVSS specification, and a failure threshold set too high means the gate never fires.

Keeping dependencies current matters as much as scanning them. A library pinned years back accumulates known issues faster than any one of them gets triaged.

A mail library that writes an unvalidated string into a message header lets an attacker who controls that string inject a line feed and start a header of their own — adding recipients, rewriting the sender or attaching content. The subject line is the usual entry point. Strip CR and LF from any user-supplied value before it reaches setSubject() or an address field, and use a current, maintained mail library rather than a version pinned years ago.

Where an endpoint can be driven at machine speed — sign-up, password reset, a search that is expensive to serve — rate limiting is the primary control and a CAPTCHA such as reCAPTCHA is a supplement. Loading a third-party script places trust in that origin: pin it to the vendor’s documented URL, serve it over TLS, and account for it in the Content Security Policy.