Skip to content

Commit 365da76

Browse files
authored
Merge pull request #50672 from sberyozkin/http_access_log_mask_authorization_header
Mask some HTTP headers in the HTTP access log
2 parents 9e7e287 + 1f1f4d1 commit 365da76

File tree

5 files changed

+164
-6
lines changed

5 files changed

+164
-6
lines changed

extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AccessLogConfig.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.quarkus.vertx.http.runtime;
22

33
import java.util.Optional;
4+
import java.util.Set;
45

56
import io.smallrye.config.WithDefault;
67

@@ -26,10 +27,28 @@ public interface AccessLogConfig {
2627
* - long: `%r\n%{ALL_REQUEST_HEADERS}`
2728
* <p>
2829
* Otherwise, consult the Quarkus documentation for the full list of variables that can be used.
30+
*
31+
* Note that enabling the `%{ALL_REQUEST_HEADERS}` attribute directly or with a `long` named format introduces a risk
32+
* of sensitive header values being logged.
33+
* <p>
34+
* HTTP `Authorization` header value is always masked. Use the {@link #maskedHeaders()} property to mask other sensitive
35+
* headers.
2936
*/
3037
@WithDefault("common")
3138
String pattern();
3239

40+
/**
41+
* Set of HTTP headers whose values must be masked when the `%{ALL_REQUEST_HEADERS}` attribute
42+
* is enabled with the {@link #pattern()} property.
43+
*/
44+
Optional<Set<String>> maskedHeaders();
45+
46+
/**
47+
* Set of HTTP Cookie headers whose values must be masked when the `%{ALL_REQUEST_HEADERS}` attribute
48+
* is enabled with the {@link #pattern()} property.
49+
*/
50+
Optional<Set<String>> maskedCookies();
51+
3352
/**
3453
* If logging should be done to a separate file.
3554
*/

extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/attribute/AllRequestHeadersAttribute.java

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,108 @@
11
package io.quarkus.vertx.http.runtime.attribute;
22

33
import java.util.Map;
4+
import java.util.Set;
45
import java.util.StringJoiner;
6+
import java.util.stream.Collectors;
57

8+
import org.eclipse.microprofile.config.ConfigProvider;
9+
10+
import io.quarkus.vertx.http.runtime.AccessLogConfig;
11+
import io.quarkus.vertx.http.runtime.VertxHttpConfig;
12+
import io.smallrye.config.SmallRyeConfig;
613
import io.vertx.core.MultiMap;
14+
import io.vertx.core.http.HttpHeaders;
715
import io.vertx.ext.web.RoutingContext;
816

917
public class AllRequestHeadersAttribute implements ExchangeAttribute {
1018

11-
public static final AllRequestHeadersAttribute INSTANCE = new AllRequestHeadersAttribute();
19+
private static final String AUTHORIZATION_HEADER = String.valueOf(HttpHeaders.AUTHORIZATION).toLowerCase();
20+
private static final String COOKIE_HEADER = String.valueOf(HttpHeaders.COOKIE).toLowerCase();
21+
22+
private final Set<String> maskedHeaders;
23+
private final Set<String> maskedCookies;
1224

13-
private AllRequestHeadersAttribute() {
25+
AllRequestHeadersAttribute() {
26+
this(Set.of(), Set.of());
27+
}
28+
29+
AllRequestHeadersAttribute(AccessLogConfig config) {
30+
this(config.maskedHeaders().orElse(Set.of()), config.maskedCookies().orElse(Set.of()));
31+
}
1432

33+
AllRequestHeadersAttribute(Set<String> maskedHeaders, Set<String> maskedCookies) {
34+
this.maskedHeaders = toLowerCaseStringSet(maskedHeaders);
35+
this.maskedCookies = toLowerCaseStringSet(maskedCookies);
36+
}
37+
38+
private static Set<String> toLowerCaseStringSet(Set<String> set) {
39+
return set.stream().map(String::toLowerCase).collect(Collectors.toSet());
1540
}
1641

1742
@Override
1843
public String readAttribute(RoutingContext exchange) {
19-
final MultiMap headers = exchange.request().headers();
44+
return readAttribute(exchange.request().headers());
45+
}
2046

47+
String readAttribute(MultiMap headers) {
2148
if (headers.isEmpty()) {
2249
return null;
2350
} else {
2451
final StringJoiner joiner = new StringJoiner(System.lineSeparator());
2552

2653
for (Map.Entry<String, String> header : headers) {
27-
joiner.add(header.getKey() + ": " + header.getValue());
54+
joiner.add(header.getKey() + ": " + maskHeaderValue(header.getKey(), header.getValue()));
2855
}
2956

3057
return joiner.toString();
3158
}
3259
}
3360

61+
String maskHeaderValue(String headerName, String headerValue) {
62+
if (headerValue == null) {
63+
return null;
64+
}
65+
66+
String headerNameLowerCase = headerName.toLowerCase();
67+
68+
if (AUTHORIZATION_HEADER.equals(headerNameLowerCase)) {
69+
return maskAuthorizationHeaderValue(headerValue);
70+
}
71+
72+
if (COOKIE_HEADER.equals(headerNameLowerCase)) {
73+
return maskCookieHeaderValue(headerValue);
74+
}
75+
76+
if (maskedHeaders.contains(headerNameLowerCase)) {
77+
return "...";
78+
}
79+
80+
return headerValue;
81+
}
82+
83+
private String maskAuthorizationHeaderValue(String headerValue) {
84+
int idx = headerValue.indexOf(' ');
85+
final String scheme = idx > 0 ? headerValue.substring(0, idx) : null;
86+
87+
if (scheme != null) {
88+
return scheme + " ...";
89+
} else {
90+
return "...";
91+
}
92+
}
93+
94+
private String maskCookieHeaderValue(String headerValue) {
95+
int idx = headerValue.indexOf('=');
96+
97+
final String cookieName = idx > 0 ? headerValue.substring(0, idx) : null;
98+
99+
if (cookieName != null && maskedCookies.contains(cookieName.toLowerCase())) {
100+
return cookieName + "=...";
101+
}
102+
103+
return headerValue;
104+
}
105+
34106
@Override
35107
public void writeAttribute(RoutingContext exchange, String newValue) throws ReadOnlyAttributeException {
36108
throw new ReadOnlyAttributeException("Headers", newValue);
@@ -46,7 +118,7 @@ public String name() {
46118
@Override
47119
public ExchangeAttribute build(final String token) {
48120
if (token.equals("%{ALL_REQUEST_HEADERS}")) {
49-
return INSTANCE;
121+
return new AllRequestHeadersAttribute(getConfigMapping());
50122
}
51123
return null;
52124
}
@@ -55,6 +127,12 @@ public ExchangeAttribute build(final String token) {
55127
public int priority() {
56128
return 0;
57129
}
130+
131+
private static AccessLogConfig getConfigMapping() {
132+
SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class);
133+
return config.getConfigMapping(VertxHttpConfig.class).accessLog();
134+
}
135+
58136
}
59137

60138
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.quarkus.vertx.http.runtime.attribute;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import io.vertx.core.MultiMap;
8+
9+
class AllRequestHeadersAttributeTest {
10+
11+
@Test
12+
void testHeaderValueNotMasked() {
13+
MultiMap headers = MultiMap.caseInsensitiveMultiMap();
14+
headers.add("Content-Type", "application/json");
15+
String attribute = new AllRequestHeadersAttribute().readAttribute(headers);
16+
assertEquals("Content-Type: application/json", attribute);
17+
}
18+
19+
@Test
20+
void testAuthorizationBearerValueMasked() {
21+
MultiMap headers = MultiMap.caseInsensitiveMultiMap();
22+
headers.add("Authorization", "Bearer token");
23+
String attribute = new AllRequestHeadersAttribute().readAttribute(headers);
24+
assertEquals("Authorization: Bearer ...", attribute);
25+
}
26+
27+
@Test
28+
void testAuthorizationSchemeValueMasked() {
29+
MultiMap headers = MultiMap.caseInsensitiveMultiMap();
30+
headers.add("Authorization", "DPoP token");
31+
String attribute = new AllRequestHeadersAttribute().readAttribute(headers);
32+
assertEquals("Authorization: DPoP ...", attribute);
33+
}
34+
35+
@Test
36+
void testAuthorizationValueMasked() {
37+
MultiMap headers = MultiMap.caseInsensitiveMultiMap();
38+
headers.add("authorization", "token");
39+
String attribute = new AllRequestHeadersAttribute().readAttribute(headers);
40+
assertEquals("authorization: ...", attribute);
41+
}
42+
43+
}

integration-tests/vertx-http/src/main/resources/application.properties

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ quarkus.http.access-log.enabled=true
1717
quarkus.http.access-log.log-to-file=true
1818
quarkus.http.access-log.base-file-name=quarkus-access-log
1919
quarkus.http.access-log.log-directory=target
20+
quarkus.http.access-log.pattern=%h %l %u %t "%r" %s %b \n%{ALL_REQUEST_HEADERS}
21+
quarkus.http.access-log.masked-headers=X-token
22+
quarkus.http.access-log.masked-cookies=session
2023
quarkus.http.cors.enabled=true
2124
quarkus.http.cors.origins=*
2225
quarkus.http.cors.methods=POST,GET,PUT,OPTIONS,DELETE

integration-tests/vertx-http/src/test/java/io/quarkus/it/vertx/AccessLogTestCase.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ public void testAccessLogContent() {
2929
final Path accessLogFilePath = logDirectory.resolve("quarkus-access-log.log");
3030
final String queryParamVal = UUID.randomUUID().toString();
3131
final String targetUri = "/simple/access-log-test-endpoint?foo=" + queryParamVal;
32-
RestAssured.when().get(targetUri).then().body(containsString("passed"));
32+
RestAssured.given().auth().oauth2("bearer-access-token").accept("text/plain")
33+
.header("x-Token", "xtoken")
34+
.header("Cookie", "Session=encrypted")
35+
.header("Cookie", "visitcount=1")
36+
.get(targetUri)
37+
.then().body(containsString("passed"));
3338
Awaitility.given().pollInterval(100, TimeUnit.MILLISECONDS)
3439
.atMost(10, TimeUnit.SECONDS)
3540
.untilAsserted(new ThrowingRunnable() {
@@ -48,6 +53,16 @@ public void run() throws Throwable {
4853
"access log is missing query params");
4954
Assertions.assertFalse(line.contains("?foo=" + queryParamVal + "?foo=" + queryParamVal),
5055
"access log contains duplicated query params");
56+
Assertions.assertTrue(line.contains("Accept: text/plain"),
57+
"access log doesn't contain the HTTP Accept header with a text/plain media type");
58+
Assertions.assertTrue(line.contains("Authorization: Bearer ..."),
59+
"access log must contain a masked value of the HTTP Authorizaton header's Bearer scheme");
60+
Assertions.assertTrue(line.contains("x-Token: ..."),
61+
"access log must contain a masked value of the HTTP X-Token header");
62+
Assertions.assertTrue(line.contains("Cookie: visitcount=1"),
63+
"access log doesn't contain the HTTP Cookie visitorcount header with a value 1");
64+
Assertions.assertTrue(line.contains("Cookie: Session=..."),
65+
"access log must contain a masked value of the HTTP Cookie session header");
5166
}
5267
});
5368
}

0 commit comments

Comments
 (0)