如何在Spring Framework的RestTemplate日志中屏蔽敏感数据
问题背景
开启调试模式后,Spring Web框架的RestTemplate会打印完整请求体内容。我的应用连接Keycloak获取令牌时,请求参数包含用户名、密码等敏感信息,这些内容会被明文记录在日志中,示例日志:
Writing [{client_id=[client1], grant_type=[password], username=[user1], password=[testit]}]
我试过网上拦截请求屏蔽密码的方案,但因为RestTemplate从HttpEntity中读取请求体参数,该方案无效。以下是我尝试过但未生效的拦截器代码:
public class MaskedLoggingRestTemplate2 extends RestTemplate { private Set<String> maskedParameters; private HttpEntity<MultiValueMap<String, Object>> requestEntity; public MaskedLoggingRestTemplate2(HttpEntity<MultiValueMap<String, Object>> requestEntity) { // Initialize the set of parameters to mask maskedParameters = new HashSet<>(); maskedParameters.add("password"); this.requestEntity = requestEntity; // maskedParameters.add("api_key"); // Add more parameters as needed } @Override protected <T> T doExecute(URI url, @Nullable String uriTemplate, HttpMethod method, RequestCallback requestCallback, ResponseExtractor<T> responseExtractor){ RequestCallback maskedRequestCallback = new MaskedRequestCallback(requestCallback); return super.doExecute(url, uriTemplate, method, maskedRequestCallback, responseExtractor); } private class MaskedRequestCallback implements RequestCallback { private final RequestCallback delegate; MaskedRequestCallback(RequestCallback delegate) { this.delegate = delegate; } @Override public void doWithRequest(ClientHttpRequest request) throws IOException { delegate.doWithRequest(new MaskedClientHttpRequestWrapper(request)); } } private class MaskedClientHttpRequestWrapper implements ClientHttpRequest { private final ClientHttpRequest delegate; private byte[] bodyContent; MaskedClientHttpRequestWrapper(ClientHttpRequest delegate) throws IOException { this.delegate = delegate; this.bodyContent = getMaskedBodyContent(); } @Override public HttpMethod getMethod() { return delegate.getMethod(); } @Override public URI getURI() { return delegate.getURI(); } @Override public HttpHeaders getHeaders() { return delegate.getHeaders(); } @Override public OutputStream getBody() throws IOException { FastByteArrayOutputStream byteArrayOutputStream = new FastByteArrayOutputStream(); byteArrayOutputStream.write(bodyContent); return byteArrayOutputStream; //return delegate.getBody(); } @Override public ClientHttpResponse execute() throws IOException { ClientHttpResponse response = delegate.execute(); return new MaskedClientHttpResponseWrapper(response); } private byte[] getMaskedBodyContent() throws IOException { // FastByteArrayOutputStream byteArrayOutputStream = (FastByteArrayOutputStream)delegate.getBody(); // byte[] originalBody = byteArrayOutputStream.toByteArray(); // String originalBodyString = new String(originalBody, StandardCharsets.UTF_8); MultiValueMap<String, Object> requestEntityBody = requestEntity.getBody(); //Map<String, String> maskedParameterMap = new LinkedHashMap<>(); assert requestEntityBody != null; LinkedHashMap<String, Object> maskedParameterMap = requestEntityBody.entrySet().stream() .collect(LinkedHashMap::new, (m, entry) -> { m.put(entry.getKey(), maskedParameters.contains(entry.getKey()) ? "******" : entry.getValue()); }, LinkedHashMap::putAll); // Split the body into individual parameters // String[] parameters = originalBodyString.split("&"); // Map<String, String> maskedParameterMap = new LinkedHashMap<>(); // Mask each parameter as needed // for (String parameter : parameters) { // String[] keyValue = parameter.split("="); // if (keyValue.length == 2) { // String key = keyValue[0]; // String value = keyValue[1]; // if (maskedParameters.contains(key)) { // value = "********"; // Mask the parameter value // } // maskedParameterMap.put(key, value); // } // } // Recreate the body content with masked parameters StringBuilder maskedBodyBuilder = new StringBuilder(); for (Map.Entry<String, Object> entry : maskedParameterMap.entrySet()) { maskedBodyBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&"); } if (maskedBodyBuilder.length() > 0) { maskedBodyBuilder.deleteCharAt(maskedBodyBuilder.length() - 1); } return maskedBodyBuilder.toString().getBytes(StandardCharsets.UTF_8); } } private class MaskedClientHttpResponseWrapper implements ClientHttpResponse { private final ClientHttpResponse delegate; MaskedClientHttpResponseWrapper(ClientHttpResponse delegate) { this.delegate = delegate; } @Override public HttpStatusCode getStatusCode() throws IOException { return delegate.getStatusCode(); } @Override public String getStatusText() throws IOException { return delegate.getStatusText(); } @Override public void close() { } @Override public InputStream getBody() throws IOException { return delegate.getBody(); } @Override public HttpHeaders getHeaders() { return delegate.getHeaders(); } // Implement other delegating methods as needed } }
补充说明:RestTemplate的logBody是私有方法,无法重写,且该方法会被doWithRequest调用。我的需求是在RestTemplate日志中屏蔽密码等敏感数据,或者直接停止打印请求体内容。
可行解决方案
方案1:直接关闭请求体日志输出
调整日志配置,将RestTemplate相关日志的级别从DEBUG提升到INFO或更高,这样就不会打印请求体内容。以logback为例,在logback.xml中添加:
<logger name="org.springframework.web.client.RestTemplate" level="INFO"/>
如果需要保留其他调试日志,也可以更精准地控制:
<logger name="org.springframework.web.client.RestTemplate" level="DEBUG"> <filter class="ch.qos.logback.core.filter.EvaluatorFilter"> <evaluator> <expression>message contains "Writing"</expression> </evaluator> <OnMismatch>NEUTRAL</OnMismatch> <OnMatch>DENY</OnMatch> </filter> </logger>
方案2:自定义ClientHttpRequestInterceptor实现敏感信息掩码
之前的方案无效是因为没有正确拦截日志打印的时机,改用ClientHttpRequestInterceptor配合自定义日志输出逻辑:
public class MaskingRequestInterceptor implements ClientHttpRequestInterceptor { private static final Set<String> SENSITIVE_PARAMS = Set.of("password", "client_secret"); @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { // 打印掩码后的请求体 logMaskedRequest(request, body); return execution.execute(request, body); } private void logMaskedRequest(HttpRequest request, byte[] body) { if (body.length == 0 || !request.getHeaders().getContentType().includes(MediaType.APPLICATION_FORM_URLENCODED)) { // 非表单请求直接跳过或打印原内容 return; } String bodyStr = new String(body, StandardCharsets.UTF_8); String[] params = bodyStr.split("&"); StringBuilder maskedBody = new StringBuilder(); for (String param : params) { String[] keyValue = param.split("=", 2); if (keyValue.length != 2) { maskedBody.append(param).append("&"); continue; } String key = keyValue[0]; String value = SENSITIVE_PARAMS.contains(key) ? "******" : keyValue[1]; maskedBody.append(key).append("=").append(value).append("&"); } if (maskedBody.length() > 0) { maskedBody.deleteCharAt(maskedBody.length() - 1); } // 手动打印掩码后的日志 LoggerFactory.getLogger(RestTemplate.class).debug("Writing [{}]", maskedBody); } }
然后给RestTemplate添加拦截器:
RestTemplate restTemplate = new RestTemplate(); restTemplate.getInterceptors().add(new MaskingRequestInterceptor());
同时记得关闭RestTemplate自带的DEBUG日志,避免重复打印。
方案3:替换RestTemplate的请求工厂(进阶)
如果需要深度定制,可以自定义ClientHttpRequestFactory,包装请求对象,在getBody()方法中记录掩码后的内容,但这种方式复杂度较高,适合需要精细控制的场景。
内容的提问来源于stack exchange,提问作者P.Sanjay

