javaspring-bootjackson-databindjsonserializer

Masking applied to exceptions unexpectedly


I'm running into a strange situation where if an exception is thrown when calling an endpoint in my application, the exception details are masked.

This is unexpected behavior because I only want to apply the masking for certain fields in my objects.

Here's the code I have setup so far:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Masked {
}

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;

public class MaskingSerializer extends JsonSerializer<String> {

    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (value != null) {
            gen.writeString(value.replaceAll("(?<=.{4}).", "*"));
        } else {
            gen.writeNull();
        }
    }
}

//JacksonConfig
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
 return builder -> builder.modules(new SimpleModule().addSerializer(String.class, new MaskingSerializer()));
}

//Use annotation like below
@Masked
@JsonSerialize(using = MaskingSerializer.class)
private String sensitiveField;

Sample JSON with masking that shouldn't be happening:

{
    "timestamp": "2025-03-16T21:03:46.796+00:00",
    "status": 404,
    "error": "Not *****",
    "message": "No s******************************************************",
    "path": "/man***********************************"
}

I expect only when I use @Masked annotation then the masking is applied (only on serializing field level) but I noticed if there's an error in an api call, the response back is masked per this rule which is odd. Why does this happen?


Solution

  • This is the problematic line:

    .addSerializer(String.class, new MaskingSerializer()));
    

    That tells the serializer to serialize all String objects with the provided serializer. error, message, and path are all strings, so they are all serialized with your MaskingSerializer.

    Your intent is that only fields annotated with @Masked should be serialized with it.

    You've made a Masked annotation but absolutely nowhere in your code is that linked to MaskingSerializer. You did add @JsonSerialize(using = MaskingSerializer.class) which would work fine - just.. do not add that serializer.

    That's one simple way to accomplish your goal: Do use @JsonSerialize, ditch @Masked (you aren't using it at all), and do not register the serializer as that would then apply it to every String.

    If you actually want that annotation, you would register the serializer but it needs a filtering operation: It needs to check if the field it is trying to serialize has the annotation. To do this, you need to make a ContextualSerializer instead, and write in your impl of the createContextual method a check on whether a @Masked annotation is present (via the property param you will receive; it has a getAnnotations() method), and then return either the default string serializer if it is not present, or your masked serializer if it is.

    Serialization is a complex beast and it seems like you think it isn't and just sort of haphazardly strung some annotations and calls together in the vein hope that it'll all just sort of work. Serialization is like that: It seems so simple. It just isn't. Reading the docs is important. Be aware that fully understanding what it all implies is difficult even if you do. Anytime you think 'this is too complicated; surely it is simpler' suppress that thought for it will lead you astray: It isn't simple at all.