Skip to content

Fix masked error responses on XML REST endpoints (#403) - #405

Open
assadriaz wants to merge 2 commits into
masterfrom
fix/403-error-response-masking
Open

Fix masked error responses on XML REST endpoints (#403)#405
assadriaz wants to merge 2 commits into
masterfrom
fix/403-error-response-masking

Conversation

@assadriaz

Copy link
Copy Markdown
Contributor

Summary

Fixes masked error responses on XML-producing REST endpoints (#403).

Any exception thrown while handling a request on a @Produces(application/xml) endpoint
(e.g. the NeTEx import endpoint POST /services/stop_places/netex) was returned to the
client as an opaque, generic HTTP 500 — the real cause was discarded and only visible in
the server log. This made every import/validation failure look identical and slowed down
diagnosis considerably.

Root cause (two layers):

  1. GeneralExceptionMapper#toResponse built the response without setting a media type, so
    it inherited the resource's @Produces (application/xml). The only writer for
    ErrorResponseEntity is @Produces("text/plain"), so no writer matched →
    MessageBodyProviderNotFoundException → generic 500, message dropped.
  2. Once the response is pinned to text/plain, ErrorResponseEntityMessageBodyWriter is
    actually invoked — and it then NPE'd for exceptions with no message (e.g. a bare
    NullPointerException), because PrintWriter.write(String) throws on null. So the error
    still failed to serialize, just one level deeper.

Changes:

  • GeneralExceptionMapper — pin error responses to text/plain so the existing writer matches.
  • NoSuchElementMapper — same fix; also switched from setting the raw (un-writable) exception
    as the entity to a text/plain ErrorResponseEntity.
  • Both mappers — fall back to the exception's toString() (class name) when getMessage() is
    null, so the client gets something meaningful instead of a blank line.
  • ErrorResponseEntityMessageBodyWriter — null-guard the write so it can never NPE on a null message.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Issue

Closes #403.

  • Motivation: while importing a third-party NeTEx file, four distinct failures all surfaced
    as the same opaque {"status":500,"error":"Internal Server Error"}; the actual causes (schema
    parse error, missing time zone, non-numeric version, non-conforming IDs) were only recoverable
    from spring.log.
  • How it works: JAX-RS selects a MessageBodyWriter by the response's media type. By
    explicitly setting text/plain (the type ErrorResponseEntityMessageBodyWriter already
    produces) the writer is always matched, regardless of the resource's @Produces.
  • Design note: text/plain was chosen because it matches the existing writer and keeps the
    Content-Type honest. An alternative (broadening the writer's @Produces to application/xml/
    application/json) would label a plain-text body as XML/JSON, so it was not taken.

Unit tests

  • GeneralExceptionMapperTest — added assertions that the response media type is text/plain,
    that the cause message is carried through, and that an exception without a message still
    yields a non-null message (NullPointerException → class name).
  • NoSuchElementMapperTest (new) — asserts 404 + text/plain + serialized message.
  • ErrorResponseEntityMessageBodyWriterTest (new) — writes a normal message, and reproduces the
    null-message case to prove the writer no longer NPEs.
  • All four exception-mapper test classes pass locally on JDK 25 (mvn test -Dtest=..., 11 tests, BUILD SUCCESS).

Documentation

  • Added inline comments at each change site explaining why the media type is pinned and why the
    null-guard / toString() fallback are needed.

Error mappers built an ErrorResponseEntity response without setting a
media type, so on @produces(application/xml) endpoints (e.g. the NeTEx
import endpoint) no MessageBodyWriter matched ErrorResponseEntity. Jersey
then raised MessageBodyProviderNotFoundException and fell back to a generic
HTTP 500, discarding the real cause — every import/validation failure
looked identical and the message was only visible in the server log.

Pin error responses to text/plain (the type the existing
ErrorResponseEntityMessageBodyWriter already produces) in:
- GeneralExceptionMapper
- NoSuchElementMapper (also switched from the un-writable raw exception
  entity to a text/plain ErrorResponseEntity)

Added unit tests asserting the response media type and serialized message.
Once error responses are pinned to text/plain (previous commit), the
ErrorResponseEntityMessageBodyWriter is actually invoked on the XML
endpoints. It then NPEs for exceptions with no message (e.g. a bare
NullPointerException): PrintWriter.write(String) throws on null, so the
error response still fails to serialize — masking one level deeper.

- ErrorResponseEntityMessageBodyWriter: null-guard the write.
- GeneralExceptionMapper / NoSuchElementMapper: fall back to the
  exception's toString() (class name) when getMessage() is null, so the
  client gets a meaningful message instead of a blank line.

Added a writer test that reproduces the null-message NPE, and a mapper
test for the no-message case.
@assadriaz
assadriaz marked this pull request as ready for review June 24, 2026 07:19
@assadriaz
assadriaz requested a review from teppope June 24, 2026 07:20
@teppope

teppope commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

It's better than the old implementation. Did you consider returning the error in XML? Something like (untested)

  1. Add a no-arg constructor to Error inner class

JAXB requires it for deserialization/marshalling:


   public static class Error {
       public String message;
   
       public Error() {}  // required by JAXB
   
       public Error(String message) {
           this.message = message;
       }
   }

  1. Remove ErrorResponseEntityMessageBodyWriter

It's no longer needed — Jersey's built-in JAXB provider handles @XmlRootElement classes automatically when the response type is application/xml.

  1. Set the content type explicitly in the mappers
   return Response.status(status)
                  .entity(new ErrorResponseEntity(rootCause.getMessage()))
                  .type(MediaType.APPLICATION_XML_TYPE)  // ← this
                  .build();

Same change in NoSuchElementMapper.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REST/import errors on XML endpoints masked as opaque HTTP 500 (real cause discarded)

2 participants