From 05b30b8da69386e664fde3faf2240036371761b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Tue, 1 Oct 2024 16:09:32 +0200 Subject: [PATCH] Parse X-Forwarded-Prefix request header (#3436) When `reactor.netty.http.server.HttpServer#forwarded(boolean)` is configured with `true`, the X-Forwarded-Prefix header is parsed and can be obtained via `HttpServerRequest#forwardedPrefix()`. Resolves #3432 --- reactor-netty-http/build.gradle | 1 + .../netty/http/server/ConnectionInfo.java | 33 ++++++- .../DefaultHttpForwardedHeaderHandler.java | 41 ++++++++- .../http/server/HttpServerOperations.java | 5 ++ .../netty/http/server/HttpServerRequest.java | 10 ++- .../http/server/ConnectionInfoTests.java | 89 ++++++++++++++++++- .../CustomXForwardedHeadersHandler.java | 9 +- 7 files changed, 183 insertions(+), 5 deletions(-) diff --git a/reactor-netty-http/build.gradle b/reactor-netty-http/build.gradle index 19b93d4312..c3bcd133a3 100644 --- a/reactor-netty-http/build.gradle +++ b/reactor-netty-http/build.gradle @@ -249,6 +249,7 @@ task japicmp(type: JapicmpTask) { compatibilityChangeExcludes = [ "METHOD_NEW_DEFAULT" ] methodExcludes = [ + "reactor.netty.http.server.HttpServerRequest#forwardedPrefix()" ] } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/ConnectionInfo.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/ConnectionInfo.java index 04e3134f98..496c93a6ea 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/ConnectionInfo.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/ConnectionInfo.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2018-2024 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,6 +57,9 @@ public final class ConnectionInfo { final boolean isInetAddress; + @Nullable + final String forwardedPrefix; + static ConnectionInfo from(Channel channel, HttpRequest request, boolean secured, SocketAddress remoteAddress, @Nullable BiFunction forwardedHeaderHandler) { String hostName = DEFAULT_HOST_NAME; @@ -94,12 +97,18 @@ static ConnectionInfo from(Channel channel, HttpRequest request, boolean secured ConnectionInfo(SocketAddress hostAddress, String hostName, int hostPort, SocketAddress remoteAddress, String scheme, boolean isInetAddress) { + this(hostAddress, hostName, hostPort, remoteAddress, scheme, isInetAddress, null); + } + + ConnectionInfo(SocketAddress hostAddress, String hostName, int hostPort, + SocketAddress remoteAddress, String scheme, boolean isInetAddress, @Nullable String forwardedPrefix) { this.hostAddress = hostAddress; this.hostName = hostName; this.hostPort = hostPort; this.isInetAddress = isInetAddress; this.remoteAddress = remoteAddress; this.scheme = scheme; + this.forwardedPrefix = forwardedPrefix; } /** @@ -173,6 +182,18 @@ public ConnectionInfo withScheme(String scheme) { return new ConnectionInfo(this.hostAddress, this.hostName, this.hostPort, this.remoteAddress, scheme, this.isInetAddress); } + /** + * Return a new {@link ConnectionInfo} with the forwardedPrefix set. + * @param forwardedPrefix the prefix provided via X-Forwarded-Prefix header + * @return a new {@link ConnectionInfo} + * @since 1.1.23 + */ + public ConnectionInfo withForwardedPrefix(String forwardedPrefix) { + requireNonNull(forwardedPrefix, "forwardedPrefix"); + return new ConnectionInfo(this.hostAddress, this.hostName, this.hostPort, this.remoteAddress, this.scheme, + this.isInetAddress, forwardedPrefix); + } + /** * Returns the connection host name. * @return the connection host name @@ -191,6 +212,16 @@ public int getHostPort() { return hostPort != -1 ? hostPort : getDefaultHostPort(scheme); } + /** + * Returns the X-Forwarded-Prefix if it was part of the request headers. + * @return the X-Forwarded-Prefix + * @since 1.1.23 + */ + @Nullable + public String getForwardedPrefix() { + return forwardedPrefix; + } + /** * Returns the default host port number based on scheme. * @param scheme a connection scheme like "http", "https", or "wss" diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/DefaultHttpForwardedHeaderHandler.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/DefaultHttpForwardedHeaderHandler.java index 4d923eb26b..3ba3d1fcd9 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/DefaultHttpForwardedHeaderHandler.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/DefaultHttpForwardedHeaderHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2020-2024 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ */ package reactor.netty.http.server; +import java.util.ArrayList; +import java.util.StringTokenizer; import java.util.function.BiFunction; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -39,11 +41,14 @@ final class DefaultHttpForwardedHeaderHandler implements BiFunction 1 && rawPrefix.charAt(endIndex - 1) == '/') { + endIndex--; + } + prefix.append((endIndex != rawPrefix.length() ? rawPrefix.substring(0, endIndex) : rawPrefix)); + } + String parsedPrefix = prefix.toString(); + if (!parsedPrefix.isEmpty() && DEFAULT_FORWARDED_HEADER_VALIDATION && parsedPrefix.charAt(0) != '/') { + throw new IllegalArgumentException("X-Forwarded-Prefix did not start with a slash (\"/\"): " + prefixHeader); + } + return parsedPrefix; + } + + private static String[] tokenizeToStringArray(String str) { + StringTokenizer st = new StringTokenizer(str, ","); + ArrayList tokens = new ArrayList<>(); + while (st.hasMoreTokens()) { + String token = st.nextToken().trim(); + if (!token.isEmpty()) { + tokens.add(token); + } + } + return !tokens.isEmpty() ? tokens.toArray(EMPTY_STRING_ARRAY) : EMPTY_STRING_ARRAY; + } } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java index 01a8c1fcdf..7a43c44351 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java @@ -529,6 +529,11 @@ public ZonedDateTime timestamp() { return timestamp; } + @Override + public String forwardedPrefix() { + return connectionInfo.getForwardedPrefix(); + } + @Override @SuppressWarnings("unchecked") public NettyOutbound send(Publisher source) { diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java index 77b5e9d61c..a65118e8c6 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRequest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2011-2024 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -158,4 +158,12 @@ default Flux receiveContent() { * @since 1.0.28 */ ZonedDateTime timestamp(); + + /** + * Returns the X-Forwarded-Prefix if it was part of the request headers. + * @return the X-Forwarded-Prefix + * @since 1.1.23 + */ + @Nullable + String forwardedPrefix(); } diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/server/ConnectionInfoTests.java b/reactor-netty-http/src/test/java/reactor/netty/http/server/ConnectionInfoTests.java index 17d42f2da2..0bc45f72c4 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/server/ConnectionInfoTests.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/server/ConnectionInfoTests.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2018-2024 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,6 +42,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Mono; @@ -218,6 +219,7 @@ void xForwardedFor(boolean useCustomForwardedHandler) { serverRequest -> { Assertions.assertThat(serverRequest.remoteAddress().getHostString()).isEqualTo("1abc:2abc:3abc:0:0:0:5abc:6abc"); Assertions.assertThat(serverRequest.remoteAddress().getPort()).isEqualTo(8080); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -234,6 +236,7 @@ void xForwardedHost(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostAddress().getPort()).isEqualTo(port); Assertions.assertThat(serverRequest.hostName()).isEqualTo("1abc:2abc:3abc:0:0:0:5abc:6abc"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(port); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -250,6 +253,7 @@ void xForwardedHostEmptyHostHeader(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostAddress().getPort()).isEqualTo(port); Assertions.assertThat(serverRequest.hostName()).isEqualTo("1abc:2abc:3abc:0:0:0:5abc:6abc"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(port); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -265,6 +269,7 @@ void xForwardedHostPortIncluded(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostAddress().getPort()).isEqualTo(9090); Assertions.assertThat(serverRequest.hostName()).isEqualTo("1abc:2abc:3abc:0:0:0:5abc:6abc"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(9090); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -282,6 +287,7 @@ void xForwardedHostAndPort(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostAddress().getPort()).isEqualTo(8080); Assertions.assertThat(serverRequest.hostName()).isEqualTo("192.168.0.1"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(8080); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -299,6 +305,78 @@ void xForwardedHostPortIncludedAndXForwardedPort(boolean useCustomForwardedHandl Assertions.assertThat(serverRequest.hostAddress().getPort()).isEqualTo(8080); Assertions.assertThat(serverRequest.hostName()).isEqualTo("192.168.0.1"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(8080); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); + }, + useCustomForwardedHandler); + } + + @ParameterizedTest(name = "{displayName}({arguments})") + @ValueSource(booleans = {true, false}) + void xForwardedPrefix(boolean useCustomForwardedHandler) { + testClientRequest( + clientRequestHeaders -> { + clientRequestHeaders.add("X-Forwarded-Prefix", "/test-prefix"); + }, + serverRequest -> { + Assertions.assertThat(serverRequest.forwardedPrefix()).isEqualTo("/test-prefix"); + }, + useCustomForwardedHandler); + } + + @Test + void xForwardedPrefixWithoutForwardSlash() { + testClientRequest( + clientRequestHeaders -> { + clientRequestHeaders.add("X-Forwarded-Prefix", "forward-slash-missing"); + }, + serverRequest -> { + + }, + null, + httpClient -> httpClient, + httpServer -> httpServer.port(8080), + false, + true); + } + + @ParameterizedTest + @CsvSource(value = { + "/first,/second | /first/second", + "/first,/second/ | /first/second", + "/first/,/second/ | /first/second", + "/first/,/second// | /first/second" + }, delimiter = '|') + void xForwardedPrefixDelimited(String input, String output) { + testClientRequest( + clientRequestHeaders -> { + clientRequestHeaders.add("X-Forwarded-Prefix", input); + }, + serverRequest -> { + Assertions.assertThat(serverRequest.forwardedPrefix()).isEqualTo(output); + }, + false); + } + + @ParameterizedTest(name = "{displayName}({arguments})") + @ValueSource(booleans = {true, false}) + void xForwardedPrefixEmpty(boolean useCustomForwardedHandler) { + testClientRequest( + clientRequestHeaders -> { + clientRequestHeaders.add("X-Forwarded-Prefix", ""); + }, + serverRequest -> { + Assertions.assertThat(serverRequest.forwardedPrefix()).isEqualTo(""); + }, + useCustomForwardedHandler); + } + + @ParameterizedTest(name = "{displayName}({arguments})") + @ValueSource(booleans = {true, false}) + void xForwardedPrefixMissing(boolean useCustomForwardedHandler) { + testClientRequest( + clientRequestHeaders -> {}, + serverRequest -> { + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -314,6 +392,7 @@ void xForwardedMultipleHeaders(boolean useCustomForwardedHandler) { clientRequestHeaders.add("X-Forwarded-Port", "8081"); clientRequestHeaders.add("X-Forwarded-Proto", "http"); clientRequestHeaders.add("X-Forwarded-Proto", "https"); + clientRequestHeaders.add("X-Forwarded-Prefix", "/test-prefix"); }, serverRequest -> { Assertions.assertThat(serverRequest.hostAddress().getHostString()).isEqualTo("192.168.0.1"); @@ -321,6 +400,7 @@ void xForwardedMultipleHeaders(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostName()).isEqualTo("192.168.0.1"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(8080); Assertions.assertThat(serverRequest.scheme()).isEqualTo("http"); + Assertions.assertThat(serverRequest.forwardedPrefix()).isEqualTo("/test-prefix"); }, useCustomForwardedHandler); } @@ -339,6 +419,7 @@ void xForwardedHostAndEmptyPort(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostAddress().getPort()).isEqualTo(port); Assertions.assertThat(serverRequest.hostName()).isEqualTo("192.168.0.1"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(port); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, getForwardedHandler(useCustomForwardedHandler), httpClient -> httpClient, @@ -359,6 +440,7 @@ void xForwardedHostAndNonNumericPort(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostAddress().getPort()).isEqualTo(8080); Assertions.assertThat(serverRequest.hostName()).isEqualTo("192.168.0.1"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(8080); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, getForwardedHandler(useCustomForwardedHandler), httpClient -> httpClient, @@ -382,6 +464,7 @@ void xForwardedForHostAndPort(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostAddress().getPort()).isEqualTo(8080); Assertions.assertThat(serverRequest.hostName()).isEqualTo("a.example.com"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(8080); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -403,6 +486,7 @@ void xForwardedForHostAndPortAndProto(boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostName()).isEqualTo("a.example.com"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(8080); Assertions.assertThat(serverRequest.scheme()).isEqualTo("http"); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -424,6 +508,7 @@ void xForwardedForMultipleHostAndPortAndProto(boolean useCustomForwardedHandler) Assertions.assertThat(serverRequest.hostName()).isEqualTo("a.example.com"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(8080); Assertions.assertThat(serverRequest.scheme()).isEqualTo("http"); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } @@ -449,6 +534,7 @@ void xForwardedForAndPortOnly(boolean useCustomForwardedHandler) throws SSLExcep Assertions.assertThat(serverRequest.hostPort()).isEqualTo(8443); Assertions.assertThat(serverRequest.hostName()).isEqualTo("a.example.com"); Assertions.assertThat(serverRequest.scheme()).isEqualTo("https"); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, getForwardedHandler(useCustomForwardedHandler), httpClient -> httpClient.secure(ssl -> ssl.sslContext(clientSslContext)), @@ -471,6 +557,7 @@ void xForwardedProtoOnly(String protocol, boolean useCustomForwardedHandler) { Assertions.assertThat(serverRequest.hostName()).isEqualTo("a.example.com"); Assertions.assertThat(serverRequest.hostPort()).isEqualTo(getDefaultHostPort(protocol)); Assertions.assertThat(serverRequest.scheme()).isEqualTo(protocol); + Assertions.assertThat(serverRequest.forwardedPrefix()).isNull(); }, useCustomForwardedHandler); } diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/server/forwardheaderhandler/CustomXForwardedHeadersHandler.java b/reactor-netty-http/src/test/java/reactor/netty/http/server/forwardheaderhandler/CustomXForwardedHeadersHandler.java index b7adb0ac2d..c0bb688988 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/server/forwardheaderhandler/CustomXForwardedHeadersHandler.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/server/forwardheaderhandler/CustomXForwardedHeadersHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2023-2024 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,6 +36,7 @@ public final class CustomXForwardedHeadersHandler { static final String X_FORWARDED_HOST_HEADER = "X-Forwarded-Host"; static final String X_FORWARDED_PORT_HEADER = "X-Forwarded-Port"; static final String X_FORWARDED_PROTO_HEADER = "X-Forwarded-Proto"; + static final String X_FORWARDED_PREFIX_HEADER = "X-Forwarded-Prefix"; private CustomXForwardedHeadersHandler() { } @@ -74,6 +75,12 @@ private ConnectionInfo parseXForwardedInfo(ConnectionInfo connectionInfo, HttpRe throw new IllegalArgumentException("Failed to parse a port from " + portHeader); } } + + String prefixHeader = request.headers().get(X_FORWARDED_PREFIX_HEADER); + if (prefixHeader != null) { + connectionInfo = connectionInfo.withForwardedPrefix(prefixHeader); + } + return connectionInfo; } }