JettyErrorHandlerCustomizer.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.configuration;
import org.eclipse.jetty.ee11.servlet.ErrorPageErrorHandler;
import org.eclipse.jetty.ee11.webapp.AbstractConfiguration;
import org.eclipse.jetty.ee11.webapp.WebAppContext;
import org.eclipse.jetty.http.HttpField;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.HttpURI;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.handler.ErrorHandler;
import org.eclipse.jetty.util.StringUtil;
import org.springframework.boot.jetty.servlet.JettyServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Writer;
import java.nio.charset.Charset;
import java.time.LocalDateTime;
/**
* Replaces Jetty's built-in error handlers with sanitized variants.
* <p>
* Errors that escape Spring MVC — exceptions thrown in servlet filters (e.g. Jetty's
* "Unable to parse form content" raised by {@code getParameter()} on a request with a malformed
* {@code Content-Type} header), or requests Jetty rejects before they reach the servlet context —
* are rendered by Jetty's default {@link ErrorHandler}, not by
* {@code AppExceptionHandler} ({@code ErrorMvcAutoConfiguration} is excluded, so there is no
* {@code /error} dispatch fallback either). Jetty's default output leaks the raw connector-level
* request URL (internal host and port as seen behind a reverse proxy), the servlet name
* ("origin") and the internal exception message.
* <p>
* The sanitized handlers emit only the HTTP status, its reason phrase, the request path and a
* timestamp — mirroring the {@code ErrorResponse} envelope used by {@code AppExceptionHandler}.
* <p>
* Two levels need replacing:
* <ul>
* <li>the servlet context's error handler, which Spring Boot installs as a {@link WebAppContext}
* {@code Configuration} at context start — appended configurations run after Boot's, so the
* replacement happens in one of those;</li>
* <li>the server-level error handler, which renders failures that never reach the servlet
* context (malformed request line or headers).</li>
* </ul>
*/
@Component
public class JettyErrorHandlerCustomizer implements WebServerFactoryCustomizer<JettyServletWebServerFactory> {
@Override
public void customize(JettyServletWebServerFactory factory) {
factory.addConfigurations(new AbstractConfiguration(new AbstractConfiguration.Builder()) {
@Override
public void configure(WebAppContext context) {
context.setErrorHandler(new SanitizedServletErrorHandler());
}
});
factory.addServerCustomizers(server -> server.setErrorHandler(new SanitizedServerErrorHandler()));
}
private static void harden(ErrorHandler handler) {
handler.setShowStacks(false);
handler.setShowCauses(false);
handler.setShowOrigin(false);
handler.setShowMessageInTitle(false);
}
/**
* Handles errors raised within the servlet context (servlet filters included). Extends
* {@link ErrorPageErrorHandler} so servlet error-page dispatch would still work if error
* pages were ever registered.
*/
static class SanitizedServletErrorHandler extends ErrorPageErrorHandler {
SanitizedServletErrorHandler() {
harden(this);
}
// Match Spring Boot's JettyEmbeddedErrorHandler: error bodies for all HTTP methods,
// not only the GET/POST/HEAD default of the core handler.
@Override
public boolean errorPageForMethod(String method) {
return true;
}
@Override
protected void writeErrorHtml(Request request, Writer writer, Charset charset, int code, String message,
Throwable cause) throws IOException {
SanitizedErrorWriter.writeHtml(writer, code);
}
@Override
protected void writeErrorPlain(Request request, PrintWriter writer, int code, String message,
Throwable cause) {
SanitizedErrorWriter.writePlain(request, writer, code);
}
@Override
protected void writeErrorJson(Request request, PrintWriter writer, int code, String message,
Throwable cause) {
SanitizedErrorWriter.writeJson(request, writer, code);
}
}
/**
* Handles errors raised outside the servlet context, e.g. requests Jetty rejects while
* parsing the request line or headers.
*/
static class SanitizedServerErrorHandler extends ErrorHandler {
SanitizedServerErrorHandler() {
harden(this);
}
@Override
protected void writeErrorHtml(Request request, Writer writer, Charset charset, int code, String message,
Throwable cause) throws IOException {
SanitizedErrorWriter.writeHtml(writer, code);
}
@Override
protected void writeErrorPlain(Request request, PrintWriter writer, int code, String message,
Throwable cause) {
SanitizedErrorWriter.writePlain(request, writer, code);
}
@Override
protected void writeErrorJson(Request request, PrintWriter writer, int code, String message,
Throwable cause) {
SanitizedErrorWriter.writeJson(request, writer, code);
}
}
/**
* Emits the sanitized error body: status, reason phrase, request path (never scheme, host or
* query) and a timestamp. Field names mirror
* {@code org.entrystore.rest.springboot.model.api.ErrorResponse}.
*/
static final class SanitizedErrorWriter {
private SanitizedErrorWriter() {
}
static void writeJson(Request request, PrintWriter writer, int code) {
StringBuilder json = new StringBuilder(128);
json.append("{\"timestamp\":").append(quote(LocalDateTime.now().toString()));
json.append(",\"status\":").append(code);
String path = path(request);
if (path != null) {
json.append(",\"path\":").append(quote(path));
}
json.append(",\"error\":").append(quote(reason(code)));
json.append('}');
writer.append(json);
}
static void writePlain(Request request, PrintWriter writer, int code) {
writer.printf("HTTP ERROR %d %s%n", code, reason(code));
String path = path(request);
if (path != null) {
writer.printf("PATH: %s%n", path);
}
}
static void writeHtml(Writer writer, int code) throws IOException {
String reason = reason(code);
writer.write("<html>\n<head><title>Error " + code + " " + reason + "</title></head>\n");
writer.write("<body><h2>HTTP ERROR " + code + " " + reason + "</h2></body>\n</html>\n");
}
static String reason(int code) {
String reason = HttpStatus.getMessage(code);
return reason != null ? reason : "Error";
}
// The path is client-controlled; sanitize it for markup contexts before quoting, the same
// way Jetty's default writers do. A request rejected during parsing may have no usable URI.
private static String path(Request request) {
try {
HttpURI uri = request != null ? request.getHttpURI() : null;
String path = uri != null ? uri.getPath() : null;
return path != null ? StringUtil.sanitizeXmlString(path) : null;
} catch (RuntimeException e) {
return null;
}
}
private static String quote(String value) {
return HttpField.NAME_VALUE_TOKENIZER.quote(value);
}
}
}