Tuesday, September 10, 2024

Spring Boot request and response logging with sensitive data masking

 Enterprise Java (or any other) applications ofter requires logging of request and response bodies to investigate any upcoming issue. Logging these payloads are easy-peasy, but what should we do with any sensitive data inside those payloads? In this short article I'd like to share a solution with Spring Boot that is used in most large enterprise companies to log their incoming and outgoing messages in a secure and regulated way.

Let's start our journey with 

https://start.spring.io/

where we generate our initial application:


Let's press on "Generate" to download the starter version of the app. Once the download has completed, unzip the archive and open it in your preferred IDE (I prefer IntelliJ).

Step 1: Create the request class

Create a new class called ServiceRequest:

@Getter
@Setter
public class ServiceRequest {
private String name;
}

Step 2: Create the response class

Create a the response class called ServiceResponse that will be provided by our controller:

@Getter
@Setter
public class ServiceResponse {
private String message;
private String requestId;
}

Step 3: Create the REST controller

@RestController
public class ServiceController {

@PostMapping("/service")
public @ResponseBody ServiceResponse service(@RequestBody ServiceRequest request) {
ServiceResponse response = new ServiceResponse();
response.setMessage("Welcome to the sensitive logging demo, " + request.getName() + "!");
response.setRequestId(UUID.randomUUID().toString());
return response;
}
}

Step 4: Implement the request logger service

@Slf4j
@ControllerAdvice
public class RequestLogger implements RequestBodyAdvice {

@Autowired
private ObjectMapper objectMapper;

@Override
public boolean supports(
@NonNull MethodParameter methodParameter,
@NonNull Type targetType,
@NonNull Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}

@Override
public @NonNull HttpInputMessage beforeBodyRead(
@NonNull HttpInputMessage inputMessage,
@NonNull MethodParameter parameter,
@NonNull Type targetType,
@NonNull Class<? extends HttpMessageConverter<?>> converterType) {
return inputMessage;
}

@Override
public @NonNull Object afterBodyRead(
@NonNull Object body,
@NonNull HttpInputMessage inputMessage,
@NonNull MethodParameter parameter,
@NonNull Type targetType,
@NonNull Class<? extends HttpMessageConverter<?>> converterType) {

logRequest(body);
return body;
}

@Override
public Object handleEmptyBody(
Object body,
@NonNull HttpInputMessage inputMessage,
@NonNull MethodParameter parameter,
@NonNull Type targetType,
@NonNull Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}

private void logRequest(Object body) {
try {
log.info("Request logged: {}", objectMapper.writeValueAsString(body));
} catch (JsonProcessingException e) {
log.error("Error while logging request", e);
}
}
}

Step 5: Implement the response logger service

@Slf4j
@ControllerAdvice
public class ResponseLogger implements ResponseBodyAdvice<Object> {

@Autowired
private ObjectMapper objectMapper;

@Override
public boolean supports(@NonNull MethodParameter returnType, @NonNull Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}

@Override
public Object beforeBodyWrite(Object body,
@NonNull MethodParameter returnType,
@NonNull MediaType selectedContentType,
@NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType,
@NonNull ServerHttpRequest request,
@NonNull ServerHttpResponse response) {

logResponse(body);
return body;
}

private void logResponse(Object body) {
try {
log.info("Response: {}", objectMapper.writeValueAsString(body));
} catch (JsonProcessingException e) {
log.error("Error while logging response", e);
}
}
}

If you start the application and invoke the endpoint from Postman (or from your preferred test tool, you will see the logs:

2024-09-10T17:24:51.572+02:00  INFO 14904 --- [sensitivelogging-demo] [nio-8080-exec-1] h.p.sensitivelogging.RequestLogger       : Request logged: {"name":"Peter"}
2024-09-10T17:24:51.595+02:00  INFO 14904 --- [sensitivelogging-demo] [nio-8080-exec-1] h.p.sensitivelogging.ResponseLogger      : Response: {"message":"Welcome to the sensitive logging demo, Peter!"}

If your endpoint contains sensitive data like a name, this will be problematic, so let's solve this issue!

Step 6: Add the masking annotation

Somehow we have to mark the fields that is a sensitive data, so we need an annotation:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Sensitive {
}

Step 7: Enhance the ObjectMapper Part I: Serializer

This sample application uses the default Jackson ObjectMapper, so we have to create our custom configuration. First, let's create a serializer:
public class MaskSensitiveDataSerializer extends StdSerializer<String> {

@Serial
private static final long serialVersionUID = 1L;

public MaskSensitiveDataSerializer() {
super(String.class);
}

@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString("****");
}
}

Step 7: Enhance the ObjectMapper Part II: Annotation introspect

public class CustomJacksonAnnotationIntrospector extends JacksonAnnotationIntrospector {

@Override
public Object findSerializer(Annotated am) {
Sensitive annotation = am.getAnnotation(Sensitive.class);
return (annotation != null) ? MaskSensitiveDataSerializer.class : super.findSerializer(am);
}
}
This class will look for the Sensitive annotation on each field, and if it finds any, the serializer will replace the value with "****".

Step 8: Enhance the ObjectMapper Part III: Configuration

Let's open the main SensitiveLoggingDemoApplication.java class, and add the following beans:
@Bean
@Primary
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.registerModule(new JsonComponentModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
}

@Bean
@Qualifier("loggingObjectMapper")
public ObjectMapper loggingObjectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.registerModule(new JsonComponentModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.enable(SerializationFeature.INDENT_OUTPUT)
.setAnnotationIntrospector(new CustomJacksonAnnotationIntrospector());
}
We need 2, because the first one is the primary bean that is used to handle request and response bodies, and the second one is just for logging the payloads. If we don't separate them, then the outgoing response body will also be masked.

Step 9: Enhance Loggers

Update the ObjectMapper dependecy in the RequestLogger and ResponseLogger classes:
@Autowired
@Qualifier("loggingObjectMapper")
private ObjectMapper objectMapper;

Step 10: Add the annotation to the request and response

@Getter
@Setter
public class ServiceRequest {
@Sensitive
private String name;
}
@Getter
@Setter
public class ServiceResponse {
private String message;
@Sensitive
private String requestId;
}

If you restart the app and test again, you will see that all sensitive fields are masked properly!

2024-09-10T17:48:58.793+02:00  INFO 16564 --- [sensitivelogging-demo] [nio-8080-exec-2] h.p.sensitivelogging.RequestLogger       : Request logged: {
  "name" : "****"
}
2024-09-10T17:48:58.855+02:00  INFO 16564 --- [sensitivelogging-demo] [nio-8080-exec-2] h.p.sensitivelogging.ResponseLogger      : Response: {
  "message" : "Welcome to the sensitive logging demo, Peter!",
  "requestId" : "****"
}


The source code is available here:

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.

Configure and use VSCode for Java web development

Embarking on Java web development often starts with choosing the right tools that streamline the coding process while enhancing productivity...