CWE-209: Generation of Error Message Containing Sensitive Information - Java
Overview
CWE-209 in Java applications occurs when exception stack traces, SQL error details, or internal system information is exposed to users through HTTP responses, log files, or error pages. Java's detailed stack traces are valuable for debugging and dangerous once they reach untrusted users.
Primary Defence: Return generic error messages to users while logging detailed exceptions server-side, use @ControllerAdvice or exception handlers to centralize error handling, and sanitize all error responses.
Common Vulnerable Patterns
Returning Exception Messages Directly
// VULNERABLE - Exposes database errors and SQL queries
@RestController
public class UserController {
@GetMapping("/user/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
try {
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found"));
return ResponseEntity.ok(user);
} catch (Exception e) {
// Returns: "ORA-00942: table or view does not exist"
// or "Connection refused: connect"
return ResponseEntity.status(500)
.body(new ErrorResponse(e.getMessage()));
}
}
}
Why this is vulnerable:
- Exposes SQL error codes, table names, and column names.
- Reveals the database vendor and version.
- Leaks connection details and internal topology.
- Aids SQL injection and privilege escalation planning.
Printing Stack Traces to Response
// VULNERABLE - Exposes full stack trace with package structure
@RestController
public class OrderController {
@PostMapping("/order")
public ResponseEntity<?> createOrder(@RequestBody Order order) {
try {
return ResponseEntity.ok(orderService.process(order));
} catch (Exception e) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
e.printStackTrace(pw);
// Exposes: class names, method names, line numbers, file paths
return ResponseEntity.status(500)
.body(Map.of(
"error", e.getMessage(),
"stackTrace", sw.toString()
));
}
}
}
Why this is vulnerable:
- Exposes package structure, class names, and line numbers.
- Maps the internal architecture and the execution path through the code.
- Leaks third-party libraries and potential versions.
Spring Boot Default Error Handling
# VULNERABLE - each of these turns on an attribute Spring Boot leaves off by default
server.error.include-exception=true
server.error.include-stacktrace=always
server.error.include-message=always
server.error.include-binding-errors=always
That configuration makes BasicErrorController return:
{
"timestamp": "2026-01-15T10:30:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"exception": "java.sql.SQLException",
"message": "ORA-00001: unique constraint violated",
"trace": "java.sql.SQLException: ORA-00001...\n\tat com.example..."
}
Why this is vulnerable:
- Exposes exception class names and framework internals.
- Returns full stack traces with file paths and line numbers.
- Leaks database constraints, validation rules, or SQL fragments.
Two things about this block change how a finding here should be triaged. First, none of these are Spring Boot defaults - include-stacktrace, include-message and include-binding-errors all default to NEVER and include-exception to false (checked against ErrorProperties in Boot 3.5.6). A leaking /error response means somebody turned one on, usually to debug an incident, so look in profile-specific property files and environment overrides rather than at the base application.properties. Second, .properties files have no // comment syntax - # and ! are the comment characters, and include-exception=true // shows the class binds the string true // shows the class, which Boolean parses as false. A // annotation added to one of these lines silently disables the setting it labels, and on include-stacktrace it fails enum conversion at startup instead.
Servlet Exception Forwarding
// VULNERABLE - Default servlet error handling exposes details
@WebServlet("/process")
public class ProcessServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
try {
String data = request.getParameter("data");
processData(data);
response.getWriter().write("Success");
} catch (Exception e) {
// Container's default error page shows exception details
throw new ServletException(e);
}
}
}
Why this is vulnerable:
- Default container pages expose full stack traces.
- Reveals servlet paths, request parameters, and server version.
- Enables mapping of endpoints and internal flow.
- Helps attackers target specific server versions.
Logging Sensitive Data at DEBUG Level
// VULNERABLE - Logs passwords and tokens
@Service
public class AuthService {
private static final Logger logger = LoggerFactory.getLogger(AuthService.class);
public AuthToken login(String username, String password) {
// Logs plaintext password!
logger.debug("Login attempt - username: {}, password: {}", username, password);
User user = userRepository.findByUsername(username);
if (user != null && passwordEncoder.matches(password, user.getPasswordHash())) {
AuthToken token = generateToken(user);
// Logs sensitive token!
logger.debug("Generated token: {}", token.getValue());
return token;
}
throw new AuthenticationException("Invalid credentials");
}
}
Why this is vulnerable:
- Logs plaintext passwords, tokens, and session IDs.
- Logs may be accessible via aggregation or backups.
- Over-privileged access to those logs exposes the data.
- Enables account takeover and session hijacking.
Exposing Validation Errors
// VULNERABLE - Reveals field names and internal validation logic
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody UserRegistration registration,
BindingResult result) {
if (result.hasErrors()) {
// ObjectError.toString() renders the whole error, including the
// rejected value:
// Field error in object 'userRegistration' on field 'password':
// rejected value [hunter2]; codes [Size.userRegistration.password,
// ...]; default message [size must be between 12 and 64]
List<String> errors = result.getAllErrors().stream()
.map(ObjectError::toString)
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(errors);
}
return ResponseEntity.ok(userService.register(registration));
}
Why this is vulnerable:
ObjectError.toString()includes the rejected value, so a failed password or card-number field is echoed straight back to whoever submitted it - and into any log or error tracker that records the response.- Exposes internal field names and data model structure.
- Reveals validation patterns and business rules, including the regex a field is checked against.
- Helps attackers craft precise probes and enumeration.
The fix is not to drop validation feedback - a registration form needs to say which field was wrong. It is to build the response from the field name and a message you wrote, never from the framework's rendering of the error object. Validation Error Sanitization below does this.
Secure Patterns
Spring Boot Global Exception Handler
// SECURE - Generic errors to users, detailed logs server-side
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex,
WebRequest request) {
// Generate unique error ID for tracking
String errorId = UUID.randomUUID().toString();
// Log full details server-side
logger.error("Error ID {}: {} - Request: {}",
errorId, ex.getMessage(), request.getDescription(false), ex);
// Return generic message with tracking ID
ErrorResponse error = new ErrorResponse(
"An error occurred processing your request",
errorId,
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
"Resource not found",
null,
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
// Log but don't expose validation details
logger.warn("Validation error: {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
"Invalid input provided",
null,
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
}
@Data
@AllArgsConstructor
public class ErrorResponse {
private String message;
private String errorId;
private LocalDateTime timestamp;
}
Why this works:
- Centralizes exception handling for consistent responses.
- Logs full details server-side while returning generic messages.
- Error IDs correlate user reports to server logs safely.
- Specific handlers keep correct HTTP status codes without leaks.
Secure Spring Boot Configuration
# SECURE - Production application.properties
# Disable detailed error responses
server.error.include-exception=false
server.error.include-stacktrace=never
server.error.include-message=never
server.error.include-binding-errors=never
# Custom error path
server.error.path=/error
# Logging configuration
logging.level.root=INFO
logging.level.com.yourapp=INFO
logging.file.name=/var/log/yourapp/application.log
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} - %logger{36} - %level - %msg%n
// SECURE - Custom error controller
@Controller
public class CustomErrorController implements ErrorController {
private static final Logger logger = LoggerFactory.getLogger(CustomErrorController.class);
@RequestMapping("/error")
public ResponseEntity<ErrorResponse> handleError(HttpServletRequest request) {
Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
Exception exception = (Exception) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
String errorId = UUID.randomUUID().toString();
// Log the actual error
if (exception != null) {
logger.error("Error ID {}: {}", errorId, exception.getMessage(), exception);
}
// Return generic response based on status
HttpStatus status = HttpStatus.valueOf(statusCode != null ? statusCode : 500);
String message = status.is4xxClientError() ?
"Invalid request" : "An error occurred";
return new ResponseEntity<>(
new ErrorResponse(message, errorId, LocalDateTime.now()),
status
);
}
}
Why this works:
- The properties pin the four
/errorattributes shut rather than relying on the defaults. All four already default to off in current Spring Boot, so this is a guard against somebody turning one on for an incident and leaving it, not a change in behaviour. - The custom controller returns a generic message chosen by status class, and logs the real exception against an error ID.
- Logs stay server-side and outside the web root.
These two halves do not stack the way they look like they do.
BasicErrorController is registered @ConditionalOnMissingBean(ErrorController.class),
so declaring CustomErrorController replaces it, and the server.error.include-*
properties above have nothing left to configure - they act on the controller
you just displaced. Keep them anyway, because deleting the custom controller
later would silently hand /error back to BasicErrorController, but do not
read their presence as evidence that /error is safe. What makes it safe here
is CustomErrorController building the body itself.
JAX-RS Exception Mappers
// SECURE - JAX-RS global exception handling
@Provider
public class GenericExceptionMapper implements ExceptionMapper<Exception> {
private static final Logger logger = LoggerFactory.getLogger(GenericExceptionMapper.class);
@Override
public Response toResponse(Exception exception) {
String errorId = UUID.randomUUID().toString();
// Log full exception details
logger.error("Error ID {}: {}", errorId, exception.getMessage(), exception);
// Return generic error to client
ErrorResponse error = new ErrorResponse(
"An error occurred",
errorId,
System.currentTimeMillis()
);
return Response
.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(error)
.build();
}
}
@Provider
public class ValidationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
private static final Logger logger = LoggerFactory.getLogger(ValidationExceptionMapper.class);
@Override
public Response toResponse(ConstraintViolationException exception) {
// Log property paths, not rejected values
List<String> invalidPaths = exception.getConstraintViolations()
.stream()
.map(v -> v.getPropertyPath().toString())
.collect(Collectors.toList());
logger.warn("Validation failed for paths: {}", invalidPaths);
// Return generic validation error
ErrorResponse error = new ErrorResponse(
"Invalid input",
null,
System.currentTimeMillis()
);
return Response
.status(Response.Status.BAD_REQUEST)
.entity(error)
.build();
}
}
Why this works:
- Global mappers enforce consistent error handling.
- Full server-side logging with generic client responses.
- Separate mappers return correct status codes safely.
- Error IDs link client errors to server logs.
Servlet Error Handling
// SECURE - Servlet with proper error handling
@WebServlet("/secure-process")
public class SecureProcessServlet extends HttpServlet {
private static final Logger logger = LoggerFactory.getLogger(SecureProcessServlet.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String errorId = UUID.randomUUID().toString();
String result;
try {
// Do the work that can fail BEFORE touching the response, so the
// catch block still owns an uncommitted response to write into.
result = processData(request.getParameter("data"));
} catch (Exception e) {
// Log full error server-side
logger.error("Error ID {}: Processing failed", errorId, e);
// Return generic error to client
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json");
MAPPER.writeValue(response.getWriter(),
Map.of("error", "An error occurred", "errorId", errorId));
return;
}
response.setContentType("application/json");
// Serialize - do not concatenate. A quote or a newline in `result`
// would otherwise break out of the JSON string.
MAPPER.writeValue(response.getWriter(),
Map.of("status", "success", "result", result));
}
}
<error-page>
<error-code>404</error-code>
<location>/WEB-INF/error-pages/404.jsp</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/WEB-INF/error-pages/500.jsp</location>
</error-page>
<error-page>
<exception-type>java.lang.Exception</exception-type>
<location>/WEB-INF/error-pages/error.jsp</location>
</error-page>
Why this works:
- Try/catch prevents container default error pages.
- Server logs keep full details with an error ID.
- The body is serialized by Jackson rather than assembled with string
concatenation, so a quote or newline in
resultcannot break out of the JSON string and inject fields into the response. - The try block covers only the work that can fail, and the response is written
after it. Wrapping the write as well makes the catch block unreachable in the
way it needs to be: once the first bytes are flushed the response is
committed,
setStatusis ignored, and the client receives a 200 with the error object appended to a half-written success body. - Custom error pages handle unhandled exceptions safely.
/WEB-INFprevents direct access to error views.
Structured Logging with Redaction
// SECURE - Redacting layout to remove sensitive data
// The package has to match the class name in logback.xml below.
package com.yourapp.logging;
import ch.qos.logback.classic.PatternLayout;
import ch.qos.logback.classic.spi.ILoggingEvent;
import java.util.regex.Pattern;
public class SensitiveDataRedactingLayout extends PatternLayout {
private static final Pattern PASSWORD_PATTERN =
Pattern.compile("password[\"']?\\s*[:=]\\s*[\"']?([^\"'}\\s]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern TOKEN_PATTERN =
Pattern.compile("token[\"']?\\s*[:=]\\s*[\"']?([^\"'}\\s]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CARD_PATTERN =
Pattern.compile("\\b\\d{13,19}\\b");
private static final Pattern EMAIL_PATTERN =
Pattern.compile("\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b");
@Override
public String doLayout(ILoggingEvent event) {
String message = super.doLayout(event);
message = PASSWORD_PATTERN.matcher(message).replaceAll("password=***REDACTED***");
message = TOKEN_PATTERN.matcher(message).replaceAll("token=***REDACTED***");
message = CARD_PATTERN.matcher(message).replaceAll("***CARD***");
message = EMAIL_PATTERN.matcher(message).replaceAll("***EMAIL***");
return message;
}
}
<configuration>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>/var/log/yourapp/application.log</file>
<!-- LayoutWrappingEncoder, not PatternLayoutEncoder: see below. -->
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
<layout class="com.yourapp.logging.SensitiveDataRedactingLayout">
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</layout>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE"/>
</root>
</configuration>
Why this works:
- The layout rewrites the fully rendered log line, which is the last point
before it reaches the file. That covers the message, the MDC, and the
exception -
super.doLayoutrenders the throwable into the same string, so a secret insideex.getMessage()is redacted along with everything else. - It sits on the appender, so every logger in the application is covered regardless of which class calls it or how the record got there. No application code changes.
- Regex patterns redact common secret shapes consistently.
The encoder class is the part that has to be right. PatternLayoutEncoder
builds its own PatternLayout in start() and assigns it over whatever
<layout> was configured, so nesting a custom layout inside it silently
produces an unredacted log. Logback reports it, but only on stdout during
startup, as Could not invoke method setLayout in class PatternLayoutEncoder
and Empty or null pattern - two lines that scroll past in a container's boot
output while the application runs normally and writes plaintext passwords to
disk. LayoutWrappingEncoder is the base class that actually honours
<layout>, and the <pattern> has to move inside the <layout> element with
it, because that is where the custom layout reads it from.
Verify by experiment, not by inspection: log a line containing a fake
secret at startup in a non-production environment and grep the file for it.
Measured on logback 1.5.20, the PatternLayoutEncoder form described above wrote
password: hunter2, a bearer token, a card number and an email address to the
log in the clear; the LayoutWrappingEncoder form redacted all four, plus the
password embedded in a stack trace's exception message.
Treat pattern-based redaction as a backstop for something that leaked past
review, not as permission to log secrets. It can only catch what its patterns
describe, and a value whose key and data are separated - a password field
name in one place and the value in another - has no shape for a regex to
match.
Validation Error Sanitization
// SECURE - Sanitized validation error responses
@RestControllerAdvice
public class ValidationExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(ValidationExceptionHandler.class);
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex) {
// Log validation fields server-side without raw rejected values
List<String> invalidFields = ex.getBindingResult()
.getAllErrors()
.stream()
.map(error -> {
if (error instanceof FieldError fieldError) {
return fieldError.getField();
}
return error.getObjectName();
})
.collect(Collectors.toList());
logger.warn("Validation failed for fields: {}", invalidFields);
// Return generic error to client
ErrorResponse error = new ErrorResponse(
"Invalid input provided",
null,
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(
ConstraintViolationException ex) {
// Log property paths, not invalid values
List<String> invalidPaths = ex.getConstraintViolations()
.stream()
.map(v -> v.getPropertyPath().toString())
.collect(Collectors.toList());
logger.warn("Constraint violation for paths: {}", invalidPaths);
// Generic response
ErrorResponse error = new ErrorResponse(
"Request validation failed",
null,
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
}
Why this works:
- Logs detailed validation errors server-side only.
- Returns generic messages to clients.
- Prevents exposure of internal field names and rules.
- Global handlers keep behavior consistent.
Common Pitfalls
- A global
@RestControllerAdvicehandler, but a local@ExceptionHandlerin the controller still catches first: Spring resolves a controller-local exception handler before falling back to global advice - a forgotten local handler from an earlier version of the code can keep returningex.getMessage()even after the centralized handler is added. server.error.include-*=neverquiets/error, but a separate Actuator endpoint is still verbose: those properties only control which attributesBasicErrorControllerincludes in its response - they do not disable it. Actuator is configured independently throughmanagement.endpoint.*, so securing the application's own error responses does nothing for/actuator/health,/actuator/env, or any other management endpoint that is exposed.ex.getMessage()treated as generic, when the exception class itself concatenates a cause: some exception types (Spring's nested exceptions, JDBC wrapper exceptions) build their owngetMessage()from an inner cause's message, so callinggetMessage()on what looks like your own application exception can still surface the original SQL or I/O error text.- A custom
web.xmlerror page or@ExceptionHandlerthat only covers the synchronous request thread: exceptions thrown inside@Asyncmethods, aCompletableFuturechain, or a scheduled task don't flow through the same servlet/Spring MVC error-handling path and can bypass the sanitization entirely, surfacing through a different, unguarded channel (raw log output, an unhandled future's default logging).
Additional Resources
- CWE-209: Generation of Error Message Containing Sensitive Information
- Jakarta RESTful Web Services -
ExceptionMapper, the current equivalent of the JAX-RS exception mapping this page uses - Logback Documentation
- OWASP Error Handling Cheat Sheet
- Spring Boot Error Handling
- Spring Boot Production Best Practices