diff --git a/core/src/main/java/com/linecorp/armeria/common/AbstractHeadersSanitizerBuilder.java b/core/src/main/java/com/linecorp/armeria/common/AbstractHeadersSanitizerBuilder.java new file mode 100644 index 00000000000..20fe21bc2ae --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/AbstractHeadersSanitizerBuilder.java @@ -0,0 +1,72 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.util.Set; +import java.util.function.Function; + +import com.google.common.collect.ImmutableSet; + +/** + * A skeletal builder implementation for {@link HeadersSanitizer}. + */ +abstract class AbstractHeadersSanitizerBuilder { + + private Set maskHeaders = ImmutableSet.of(); + + private Function mask = (header) -> "****"; + + /** + * Sets the {@link Set} which includes headers to mask before logging. + */ + public AbstractHeadersSanitizerBuilder maskHeaders(String... headers) { + maskHeaders = ImmutableSet.copyOf(requireNonNull(headers, "headers")); + return this; + } + + /** + * Sets the {@link Set} which includes headers to mask before logging. + */ + public AbstractHeadersSanitizerBuilder maskHeaders(Iterable headers) { + maskHeaders = ImmutableSet.copyOf(requireNonNull(headers, "headers")); + return this; + } + + /** + * Returns the {@link Set} which includes headers to mask before logging. + */ + final Set maskHeaders() { + return maskHeaders; + } + + /** + * Returns the {@link Function} to use to mask headers before logging. + */ + final Function mask() { + return mask; + } + + /** + * Sets the {@link Function} to use to mask headers before logging. + */ + public AbstractHeadersSanitizerBuilder mask(Function mask) { + this.mask = requireNonNull(mask, "mask"); + return this; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/HeadersSanitizer.java b/core/src/main/java/com/linecorp/armeria/common/HeadersSanitizer.java new file mode 100644 index 00000000000..299edc6b81d --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/HeadersSanitizer.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.common; + +import java.util.function.BiFunction; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * A sanitizer that sanitizes {@link HttpHeaders}. + */ +public interface HeadersSanitizer extends BiFunction { + /** + * Returns the default text {@link HeadersSanitizer}. + */ + static HeadersSanitizer ofText() { + return TextHeadersSanitizer.INSTANCE; + } + + /** + * Returns a newly created {@link TextHeadersSanitizerBuilder}. + */ + static TextHeadersSanitizerBuilder builderForText() { + return new TextHeadersSanitizerBuilder(); + } + + /** + * Returns the default json {@link HeadersSanitizer}. + */ + static HeadersSanitizer ofJson() { + return JsonHeadersSanitizer.INSTANCE; + } + + /** + * Returns a newly created {@link JsonHeadersSanitizerBuilder}. + */ + static JsonHeadersSanitizerBuilder builderForJson() { + return new JsonHeadersSanitizerBuilder(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/JsonHeadersSanitizer.java b/core/src/main/java/com/linecorp/armeria/common/JsonHeadersSanitizer.java new file mode 100644 index 00000000000..feaf289f611 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/JsonHeadersSanitizer.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.common; + +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import io.netty.util.AsciiString; + +/** + * A sanitizer that sanitizes {@link HttpHeaders} and returns {@link JsonNode}. + */ +public final class JsonHeadersSanitizer implements HeadersSanitizer { + + static final HeadersSanitizer INSTANCE = new JsonHeadersSanitizerBuilder().build(); + private final Set maskHeaders; + private final Function mask; + private final ObjectMapper objectMapper; + + JsonHeadersSanitizer(Set maskHeaders, Function mask, ObjectMapper objectMapper) { + this.maskHeaders = maskHeaders; + this.mask = mask; + this.objectMapper = objectMapper; + } + + @Override + public JsonNode apply(RequestContext requestContext, HttpHeaders headers) { + final ObjectNode result = objectMapper.createObjectNode(); + for (Map.Entry e : headers) { + final String header = e.getKey().toString(); + if (maskHeaders.contains(header)) { + result.put(header, mask.apply(e.getValue())); + } else { + result.put(header, e.getValue()); + } + } + + return result; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/JsonHeadersSanitizerBuilder.java b/core/src/main/java/com/linecorp/armeria/common/JsonHeadersSanitizerBuilder.java new file mode 100644 index 00000000000..e5df8101624 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/JsonHeadersSanitizerBuilder.java @@ -0,0 +1,79 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.util.Set; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.common.JacksonUtil; + +/** + * A builder implementation for {@link JsonHeadersSanitizer}. + */ +public final class JsonHeadersSanitizerBuilder extends AbstractHeadersSanitizerBuilder { + + @Nullable + private ObjectMapper objectMapper; + + /** + * Sets the {@link Set} which includes headers to mask before logging. + */ + @Override + public JsonHeadersSanitizerBuilder maskHeaders(String... headers) { + return (JsonHeadersSanitizerBuilder) super.maskHeaders(headers); + } + + /** + * Sets the {@link Set} which includes headers to mask before logging. + */ + @Override + public JsonHeadersSanitizerBuilder maskHeaders(Iterable headers) { + return (JsonHeadersSanitizerBuilder) super.maskHeaders(headers); + } + + /** + * Sets the {@link Function} to use to mask headers before logging. + */ + @Override + public JsonHeadersSanitizerBuilder mask(Function mask) { + return (JsonHeadersSanitizerBuilder) super.mask(mask); + } + + /** + * Sets the {@link ObjectMapper} that will be used to convert an object into a JSON format message. + */ + public JsonHeadersSanitizerBuilder objectMapper(ObjectMapper objectMapper) { + this.objectMapper = requireNonNull(objectMapper, "objectMapper"); + return this; + } + + /** + * Returns a newly-created JSON {@link HeadersSanitizer} based on the properties of this builder. + */ + public JsonHeadersSanitizer build() { + final ObjectMapper objectMapper = this.objectMapper != null ? + this.objectMapper : JacksonUtil.newDefaultObjectMapper(); + + return new JsonHeadersSanitizer(maskHeaders(), mask(), objectMapper); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TextHeadersSanitizer.java b/core/src/main/java/com/linecorp/armeria/common/TextHeadersSanitizer.java new file mode 100644 index 00000000000..33f1849fa60 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/TextHeadersSanitizer.java @@ -0,0 +1,66 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.common; + +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import io.netty.util.AsciiString; + +/** + * A sanitizer that sanitizes {@link HttpHeaders} and returns {@link String}. + */ +public final class TextHeadersSanitizer implements HeadersSanitizer { + + static final HeadersSanitizer INSTANCE = new TextHeadersSanitizerBuilder().build(); + + private final Set maskHeaders; + + private final Function mask; + + TextHeadersSanitizer(Set maskHeaders, Function mask) { + this.maskHeaders = maskHeaders; + this.mask = mask; + } + + @Override + public String apply(RequestContext ctx, HttpHeaders headers) { + if (headers.isEmpty()) { + return headers.isEndOfStream() ? "[EOS]" : "[]"; + } + + final StringBuilder sb = new StringBuilder(); + if (headers.isEndOfStream()) { + sb.append("[EOS], "); + } else { + sb.append('['); + } + + for (Map.Entry e : headers) { + final String header = e.getKey().toString(); + if (maskHeaders.contains(header)) { + sb.append(header).append('=').append(mask.apply(e.getValue())).append(", "); + } else { + sb.append(header).append('=').append(e.getValue()).append(", "); + } + } + + sb.setCharAt(sb.length() - 2, ']'); + return sb.substring(0, sb.length() - 1); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TextHeadersSanitizerBuilder.java b/core/src/main/java/com/linecorp/armeria/common/TextHeadersSanitizerBuilder.java new file mode 100644 index 00000000000..4db01f375cd --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/TextHeadersSanitizerBuilder.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.common; + +import java.util.Set; +import java.util.function.Function; + +/** + * A builder implementation for {@link TextHeadersSanitizer}. + */ +public final class TextHeadersSanitizerBuilder extends AbstractHeadersSanitizerBuilder { + + /** + * Sets the {@link Set} which includes headers to mask before logging. + */ + @Override + public TextHeadersSanitizerBuilder maskHeaders(String... headers) { + return (TextHeadersSanitizerBuilder) super.maskHeaders(headers); + } + + /** + * Sets the {@link Set} which includes headers to mask before logging. + */ + @Override + public TextHeadersSanitizerBuilder maskHeaders(Iterable headers) { + return (TextHeadersSanitizerBuilder) super.maskHeaders(headers); + } + + /** + * Sets the {@link Function} to use to mask headers before logging. + */ + @Override + public TextHeadersSanitizerBuilder mask(Function mask) { + return (TextHeadersSanitizerBuilder) super.mask(mask); + } + + /** + * Returns a newly-created text {@link HeadersSanitizer} based on the properties of this builder. + */ + public TextHeadersSanitizer build() { + return new TextHeadersSanitizer(maskHeaders(), mask()); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/common/logging/JsonLogFormatterTest.java b/core/src/test/java/com/linecorp/armeria/common/logging/JsonLogFormatterTest.java index 4c1303e0492..2b61e0a86a7 100644 --- a/core/src/test/java/com/linecorp/armeria/common/logging/JsonLogFormatterTest.java +++ b/core/src/test/java/com/linecorp/armeria/common/logging/JsonLogFormatterTest.java @@ -18,16 +18,29 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.linecorp.armeria.common.HeadersSanitizer; import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.internal.common.JacksonUtil; import com.linecorp.armeria.server.ServiceRequestContext; class JsonLogFormatterTest { + private final ObjectMapper objectMapper = JacksonUtil.newDefaultObjectMapper(); + @ParameterizedTest @CsvSource({ "true", "false" }) void formatRequest() { @@ -53,4 +66,70 @@ void formatResponse() { .matches("^\\{\"type\":\"response\",\"startTime\":\".+\",\"length\":\".+\"," + "\"duration\":\".+\",\"totalDuration\":\".+\",\"headers\":\\{\".+\"}}$"); } + + @Test + void maskRequestHeaders() { + final Function maskingFunction = (header) -> "****armeria****"; + final LogFormatter logFormatter = LogFormatter.builderForJson() + .requestHeadersSanitizer( + HeadersSanitizer.builderForJson() + .maskHeaders("cookie", + "authorization") + .mask(maskingFunction) + .build()) + .build(); + final HttpRequest req = HttpRequest.of(RequestHeaders.of(HttpMethod.GET, "/hello", + "Cookie", "Armeria=awesome", + "Authorization", "Basic XXX==", + "Cache-Control", "no-cache")); + + final ServiceRequestContext ctx = ServiceRequestContext.of(req); + final DefaultRequestLog log = (DefaultRequestLog) ctx.log(); + log.endRequest(); + final String requestLog = logFormatter.formatRequest(log); + + final Matcher matcher1 = Pattern.compile("\"cookie\":\"(.*?)\"").matcher(requestLog); + assertThat(matcher1.find()).isTrue(); + assertThat(matcher1.group(1)).isEqualTo(maskingFunction.apply("Armeria=awesome")); + + final Matcher matcher2 = Pattern.compile("\"authorization\":\"(.*?)\"").matcher(requestLog); + assertThat(matcher2.find()).isTrue(); + assertThat(matcher2.group(1)).isEqualTo(maskingFunction.apply("Basic XXX==")); + + final Matcher matcher3 = Pattern.compile("\"cache-control\":\"(.*?)\"").matcher(requestLog); + assertThat(matcher3.find()).isTrue(); + assertThat(matcher3.group(1)).isEqualTo("no-cache"); + } + + @Test + void maskResponseHeaders() { + final Function maskingFunction = (header) -> "****armeria****"; + final LogFormatter logFormatter = LogFormatter.builderForJson() + .responseHeadersSanitizer( + HeadersSanitizer.builderForJson() + .maskHeaders("content-type", + "set-cookie") + .mask(maskingFunction) + .build()) + .build(); + final ServiceRequestContext ctx = ServiceRequestContext.of(HttpRequest.of(HttpMethod.GET, "/hello")); + final DefaultRequestLog log = (DefaultRequestLog) ctx.log(); + log.responseHeaders(ResponseHeaders.of(HttpStatus.OK, + "Content-Type", "text/html", + "Set-Cookie", "Armeria=awesome", + "Cache-Control", "no-cache")); + log.endResponse(); + final String responseLog = logFormatter.formatResponse(log); + final Matcher matcher1 = Pattern.compile("\"content-type\":\"(.*?)\"").matcher(responseLog); + assertThat(matcher1.find()).isTrue(); + assertThat(matcher1.group(1)).isEqualTo(maskingFunction.apply("text/html")); + + final Matcher matcher2 = Pattern.compile("\"set-cookie\":\"(.*?)\"").matcher(responseLog); + assertThat(matcher2.find()).isTrue(); + assertThat(matcher2.group(1)).isEqualTo(maskingFunction.apply("Armeria=awesome")); + + final Matcher matcher3 = Pattern.compile("\"cache-control\":\"(.*?)\"").matcher(responseLog); + assertThat(matcher3.find()).isTrue(); + assertThat(matcher3.group(1)).isEqualTo("no-cache"); + } } diff --git a/core/src/test/java/com/linecorp/armeria/common/logging/TextLogFormatterTest.java b/core/src/test/java/com/linecorp/armeria/common/logging/TextLogFormatterTest.java index 0dbc36122b4..35e1e7c6a0f 100644 --- a/core/src/test/java/com/linecorp/armeria/common/logging/TextLogFormatterTest.java +++ b/core/src/test/java/com/linecorp/armeria/common/logging/TextLogFormatterTest.java @@ -18,11 +18,20 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import com.linecorp.armeria.common.HeadersSanitizer; import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.server.ServiceRequestContext; class TextLogFormatterTest { @@ -60,4 +69,70 @@ void formatResponse(boolean containContext) { assertThat(responseLog).matches(regex); } } + + @Test + void maskRequestHeaders() { + final Function maskingFunction = (header) -> "****armeria****"; + final LogFormatter logFormatter = LogFormatter.builderForText() + .requestHeadersSanitizer( + HeadersSanitizer.builderForText() + .maskHeaders("cookie", + "authorization") + .mask(maskingFunction) + .build()) + .build(); + final HttpRequest req = HttpRequest.of(RequestHeaders.of(HttpMethod.GET, "/hello", + "Cookie", "Armeria=awesome", + "Authorization", "Basic XXX==", + "Cache-Control", "no-cache")); + + final ServiceRequestContext ctx = ServiceRequestContext.of(req); + final DefaultRequestLog log = (DefaultRequestLog) ctx.log(); + log.endRequest(); + final String requestLog = logFormatter.formatRequest(log); + System.out.println(requestLog); + final Matcher matcher1 = Pattern.compile("cookie=(.*?)[,\\]]").matcher(requestLog); + assertThat(matcher1.find()).isTrue(); + assertThat(matcher1.group(1)).isEqualTo(maskingFunction.apply("Armeria=awesome")); + + final Matcher matcher2 = Pattern.compile("authorization=(.*?)[,\\]]").matcher(requestLog); + assertThat(matcher2.find()).isTrue(); + assertThat(matcher2.group(1)).isEqualTo(maskingFunction.apply("Basic XXX==")); + + final Matcher matcher3 = Pattern.compile("cache-control=(.*?)[,\\]]").matcher(requestLog); + assertThat(matcher3.find()).isTrue(); + assertThat(matcher3.group(1)).isEqualTo("no-cache"); + } + + @Test + void maskResponseHeaders() { + final Function maskingFunction = (header) -> "****armeria****"; + final LogFormatter logFormatter = LogFormatter.builderForText() + .responseHeadersSanitizer( + HeadersSanitizer.builderForText() + .maskHeaders("content-type", + "set-cookie") + .mask(maskingFunction) + .build()) + .build(); + final ServiceRequestContext ctx = ServiceRequestContext.of(HttpRequest.of(HttpMethod.GET, "/hello")); + final DefaultRequestLog log = (DefaultRequestLog) ctx.log(); + log.responseHeaders(ResponseHeaders.of(HttpStatus.OK, + "Content-Type", "text/html", + "Set-Cookie", "Armeria=awesome", + "Cache-Control", "no-cache")); + log.endResponse(); + final String responseLog = logFormatter.formatResponse(log); + final Matcher matcher1 = Pattern.compile("content-type=(.*?)[,\\]]").matcher(responseLog); + assertThat(matcher1.find()).isTrue(); + assertThat(matcher1.group(1)).isEqualTo(maskingFunction.apply("text/html")); + + final Matcher matcher2 = Pattern.compile("set-cookie=(.*?)[,\\]]").matcher(responseLog); + assertThat(matcher2.find()).isTrue(); + assertThat(matcher2.group(1)).isEqualTo(maskingFunction.apply("Armeria=awesome")); + + final Matcher matcher3 = Pattern.compile("cache-control=(.*?)[,\\]]").matcher(responseLog); + assertThat(matcher3.find()).isTrue(); + assertThat(matcher3.group(1)).isEqualTo("no-cache"); + } }