feat: introduce ServerHttpHeaderValidator replacing ServerTransportSecurityValidator

Same feature set as ServerTransportSecurityValidator but uses a lazy
"header accessor" function to read header values instead of extracting
all headers into a map. Matches more closely what modern frameworks and
JDK APIs do.

Fixes #870

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Neeraj Bhatt 2026-09-07 14:21:21 +02:00 • committed by Daniel Garnier-Moiroux
commit 5be2fa0688

View file

@ -43,50 +43,50 @@ class DefaultServerTransportSecurityValidatorTests {
@Test
void originHeaderMissing() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(emptyAccessor())).doesNotThrowAnyException();
}
@Test
void originHeaderListEmpty() {
assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN);
assertThatCode(() -> validator.validate(headerAccessor())).doesNotThrowAnyException();
}
@Test
void caseInsensitive() {
var headers = Map.of("origin", List.of("http://localhost:8080"));
var accessor = headerAccessor("Origin", "http://localhost:8080");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void exactMatch() {
var headers = originHeader("http://localhost:8080");
var accessor = originAccessor("http://localhost:8080");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void differentPort() {
var headers = originHeader("http://localhost:3000");
var accessor = originAccessor("http://localhost:3000");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentHost() {
var headers = originHeader("http://example.com:8080");
var accessor = originAccessor("http://example.com:8080");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentScheme() {
var headers = originHeader("https://localhost:8080");
var accessor = originAccessor("https://localhost:8080");
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
}
@Nested
@ -99,37 +99,37 @@ class DefaultServerTransportSecurityValidatorTests {
@Test
void anyPortWithWildcard() {
var headers = originHeader("http://localhost:3000");
var accessor = originAccessor("http://localhost:3000");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void noPortWithWildcard() {
var headers = originHeader("http://localhost");
var accessor = originAccessor("http://localhost");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void differentPortWithWildcard() {
var headers = originHeader("http://localhost:8080");
var accessor = originAccessor("http://localhost:8080");
assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void differentHostWithWildcard() {
var headers = originHeader("http://example.com:3000");
var accessor = originAccessor("http://example.com:3000");
assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
}
@Test
void differentSchemeWithWildcard() {
var headers = originHeader("https://localhost:3000");
var accessor = originAccessor("https://localhost:3000");
assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
}
}
@ -146,23 +146,23 @@ class DefaultServerTransportSecurityValidatorTests {
@Test
void matchingOneOfMultiple() {
var headers = originHeader("http://example.com:3000");
var accessor = originAccessor("http://example.com:3000");
assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> multipleOriginsValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void matchingWildcardInMultiple() {
var headers = originHeader("http://myapp.example.com:9999");
var accessor = originAccessor("http://myapp.example.com:9999");
assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> multipleOriginsValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void notMatchingAny() {
var headers = originHeader("http://malicious.example.com:1234");
var accessor = originAccessor("http://malicious.example.com:1234");
assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
assertThatThrownBy(() -> multipleOriginsValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
}
}
@ -176,9 +176,9 @@ class DefaultServerTransportSecurityValidatorTests {
.allowedOrigins(List.of("http://localhost:8080", "http://example.com:*"))
.build();
var headers = originHeader("http://example.com:3000");
var accessor = originAccessor("http://example.com:3000");
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException();
}
@Test
@ -188,11 +188,11 @@ class DefaultServerTransportSecurityValidatorTests {
.allowedOrigins(List.of("http://example.com:*", "http://test.com:3000"))
.build();
assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080")))
assertThatCode(() -> validator.validate(originAccessor("http://localhost:8080")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999")))
assertThatCode(() -> validator.validate(originAccessor("http://example.com:9999")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000")))
assertThatCode(() -> validator.validate(originAccessor("http://test.com:3000")))
.doesNotThrowAnyException();
}
@ -210,45 +210,45 @@ class DefaultServerTransportSecurityValidatorTests {
@Test
void notConfigured() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(emptyAccessor())).doesNotThrowAnyException();
}
@Test
void missing() {
assertThatThrownBy(() -> hostValidator.validateHeaders(new HashMap<>())).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> hostValidator.validate(emptyAccessor())).isEqualTo(INVALID_HOST);
}
@Test
void listEmpty() {
assertThatThrownBy(() -> hostValidator.validateHeaders(Map.of("Host", List.of()))).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> hostValidator.validate(headerAccessor())).isEqualTo(INVALID_HOST);
}
@Test
void caseInsensitive() {
var headers = Map.of("host", List.of("localhost:8080"));
var accessor = headerAccessor("Host", "localhost:8080");
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> hostValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void exactMatch() {
var headers = hostHeader("localhost:8080");
var accessor = hostAccessor("localhost:8080");
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> hostValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void differentPort() {
var headers = hostHeader("localhost:3000");
var accessor = hostAccessor("localhost:3000");
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> hostValidator.validate(accessor)).isEqualTo(INVALID_HOST);
}
@Test
void differentHost() {
var headers = hostHeader("example.com:8080");
var accessor = hostAccessor("example.com:8080");
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> hostValidator.validate(accessor)).isEqualTo(INVALID_HOST);
}
@Nested
@ -261,23 +261,23 @@ class DefaultServerTransportSecurityValidatorTests {
@Test
void anyPort() {
var headers = hostHeader("localhost:3000");
var accessor = hostAccessor("localhost:3000");
assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void noPort() {
var headers = hostHeader("localhost");
var accessor = hostAccessor("localhost");
assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void differentHost() {
var headers = hostHeader("example.com:3000");
var accessor = hostAccessor("example.com:3000");
assertThatThrownBy(() -> wildcardHostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> wildcardHostValidator.validate(accessor)).isEqualTo(INVALID_HOST);
}
}
@ -293,30 +293,30 @@ class DefaultServerTransportSecurityValidatorTests {
@Test
void exactMatch() {
var headers = hostHeader("example.com:3000");
var accessor = hostAccessor("example.com:3000");
assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> multipleHostsValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void wildcard() {
var headers = hostHeader("myapp.example.com:9999");
var accessor = hostAccessor("myapp.example.com:9999");
assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException();
assertThatCode(() -> multipleHostsValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void differentHost() {
var headers = hostHeader("malicious.example.com:3000");
var accessor = hostAccessor("malicious.example.com:3000");
assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> multipleHostsValidator.validate(accessor)).isEqualTo(INVALID_HOST);
}
@Test
void differentPort() {
var headers = hostHeader("localhost:8080");
var accessor = hostAccessor("localhost:8080");
assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> multipleHostsValidator.validate(accessor)).isEqualTo(INVALID_HOST);
}
}
@ -330,10 +330,8 @@ class DefaultServerTransportSecurityValidatorTests {
.allowedHosts(List.of("localhost:8080", "example.com:*"))
.build();
assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:3000")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validate(hostAccessor("example.com:3000"))).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(hostAccessor("localhost:8080"))).doesNotThrowAnyException();
}
@Test
@ -343,11 +341,9 @@ class DefaultServerTransportSecurityValidatorTests {
.allowedHosts(List.of("example.com:*", "test.com:3000"))
.build();
assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:9999")))
.doesNotThrowAnyException();
assertThatCode(() -> validator.validateHeaders(hostHeader("test.com:3000"))).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(hostAccessor("localhost:8080"))).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(hostAccessor("example.com:9999"))).doesNotThrowAnyException();
assertThatCode(() -> validator.validate(hostAccessor("test.com:3000"))).doesNotThrowAnyException();
}
}
@ -365,60 +361,195 @@ class DefaultServerTransportSecurityValidatorTests {
@Test
void bothValid() {
var header = headers("http://localhost:8080", "localhost:8080");
var accessor = combinedAccessor("http://localhost:8080", "localhost:8080");
assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException();
assertThatCode(() -> combinedValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void originValidHostInvalid() {
var header = headers("http://localhost:8080", "malicious.example.com:8080");
var accessor = combinedAccessor("http://localhost:8080", "malicious.example.com:8080");
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_HOST);
}
@Test
void originInvalidHostValid() {
var header = headers("http://malicious.example.com:8080", "localhost:8080");
var accessor = combinedAccessor("http://malicious.example.com:8080", "localhost:8080");
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_ORIGIN);
assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN);
}
@Test
void originMissingHostValid() {
// Origin missing is OK (same-origin request)
var header = headers(null, "localhost:8080");
var accessor = combinedAccessor(null, "localhost:8080");
assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException();
assertThatCode(() -> combinedValidator.validate(accessor)).doesNotThrowAnyException();
}
@Test
void originValidHostMissing() {
// Host missing is NOT OK when allowedHosts is configured
var header = headers("http://localhost:8080", null);
var accessor = combinedAccessor("http://localhost:8080", null);
assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST);
assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_HOST);
}
}
private static Map<String, List<String>> originHeader(String origin) {
return Map.of("Origin", List.of(origin));
@Nested
class DeprecatedMapBasedApi {
@Test
void originValidation() {
Map<String, List<String>> headers = new HashMap<>();
headers.put("Origin", List.of("http://localhost:8080"));
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void originRejected() {
Map<String, List<String>> headers = new HashMap<>();
headers.put("Origin", List.of("http://malicious.example.com"));
assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN);
}
@Test
void caseInsensitiveHeaderLookup() {
Map<String, List<String>> headers = new HashMap<>();
headers.put("origin", List.of("http://localhost:8080"));
assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void hostValidation() {
DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator.builder()
.allowedHost("localhost:8080")
.build();
Map<String, List<String>> headers = new HashMap<>();
headers.put("Host", List.of("localhost:8080"));
assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
@Test
void hostRejected() {
DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator.builder()
.allowedHost("localhost:8080")
.build();
Map<String, List<String>> headers = new HashMap<>();
headers.put("Host", List.of("malicious.com:8080"));
assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST);
}
@Test
void emptyHeaders() {
assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException();
}
@Test
void combinedOriginAndHost() {
DefaultServerTransportSecurityValidator combinedValidator = DefaultServerTransportSecurityValidator
.builder()
.allowedOrigin("http://localhost:*")
.allowedHost("localhost:*")
.build();
Map<String, List<String>> headers = new HashMap<>();
headers.put("Origin", List.of("http://localhost:8080"));
headers.put("Host", List.of("localhost:8080"));
assertThatCode(() -> combinedValidator.validateHeaders(headers)).doesNotThrowAnyException();
}
}
private static Map<String, List<String>> hostHeader(String host) {
return Map.of("Host", List.of(host));
@Nested
class ValidatorCompatibility {
@Test
void noopAcceptsAll() {
assertThatCode(() -> ServerHttpHeaderValidator.NOOP.validate(emptyAccessor())).doesNotThrowAnyException();
assertThatCode(() -> ServerTransportSecurityValidator.NOOP.validateHeaders(new HashMap<>()))
.doesNotThrowAnyException();
}
@Test
void legacyValidatorAdaptsToHeaderAccessor() {
ServerTransportSecurityValidator legacyValidator = headers -> {
List<String> origins = headers.getOrDefault("Origin", List.of());
if (!origins.isEmpty() && origins.get(0).contains("evil")) {
throw new ServerTransportSecurityException(403, "Invalid Origin header");
}
};
ServerHttpHeaderValidator headerValidator = ServerTransportSecurityValidator
.toHttpHeaderValidator(legacyValidator);
assertThatCode(() -> headerValidator.validate(originAccessor("http://good.example.com")))
.doesNotThrowAnyException();
assertThatThrownBy(() -> headerValidator.validate(originAccessor("http://evil.example.com")))
.isEqualTo(INVALID_ORIGIN);
}
}
private static Map<String, List<String>> headers(String origin, String host) {
var map = new HashMap<String, List<String>>();
private static HeaderAccessor emptyAccessor() {
return headerAccessor();
}
private static HeaderAccessor headerAccessor(String... namesAndValues) {
Map<String, List<String>> headers = new HashMap<>();
for (int i = 0; i < namesAndValues.length; i += 2) {
headers.put(namesAndValues[i], List.of(namesAndValues[i + 1]));
}
return new HeaderAccessor() {
@Override
public List<String> getHeader(String name) {
return headers.getOrDefault(name, List.of());
}
@Override
public List<String> getHeaderNames() {
return List.copyOf(headers.keySet());
}
};
}
private static HeaderAccessor originAccessor(String origin) {
return headerAccessor("Origin", origin);
}
private static HeaderAccessor hostAccessor(String host) {
return headerAccessor("Host", host);
}
private static HeaderAccessor combinedAccessor(String origin, String host) {
Map<String, List<String>> headers = new HashMap<>();
if (origin != null) {
map.put("Origin", List.of(origin));
headers.put("Origin", List.of(origin));
}
if (host != null) {
map.put("Host", List.of(host));
headers.put("Host", List.of(host));
}
return map;
return new HeaderAccessor() {
@Override
public List<String> getHeader(String name) {
return headers.getOrDefault(name, List.of());
}
@Override
public List<String> getHeaderNames() {
return List.copyOf(headers.keySet());
}
};
}
}