diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index ffda0ff..9f82c27 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -84,6 +84,45 @@ Nevertheless, you can override the request authority: When the request authority is overridden, the `x-forwarded-host` header is automatically set on the request to the origin server with the original authority value. +==== Forwarded headers + +As a reverse proxy, it's often required to send information about the client request to the origin server through headers. +The proxy supports both the de-facto standard `X-Forwarded-*` headers and the RFC 7239 `Forwarded` header. + +By default, forwarded headers are disabled. +You can enable them by configuring {@link io.vertx.httpproxy.ForwardedHeadersOptions}: + +[source,java] +---- +{@link examples.HttpProxyExamples#forwardedHeaders} +---- + +You can control which headers are added: + +[source,java] +---- +{@link examples.HttpProxyExamples#forwardedHeadersSelective} +---- + +Alternatively, you can use the RFC 7239 standardized `Forwarded` header instead of the de-facto `X-Forwarded-*` headers: + +[source,java] +---- +{@link examples.HttpProxyExamples#forwardedHeadersRfc7239} +---- + +The following headers are supported: + +* `X-Forwarded-For` - contains the client IP address. +When a proxy chain exists, the proxy appends the client IP to preserve the chain. +* `X-Forwarded-Proto` - contains the original request scheme (http or https) +* `X-Forwarded-Host` - contains the original request host +* `X-Forwarded-Port` - contains the original request port +* `Forwarded` - RFC 7239 standardized header that combines the above information in a structured format: `for=;proto=;host=` + +IMPORTANT: The `X-Forwarded-*` and `Forwarded` headers can be used by origin servers to determine the actual client IP, protocol, and host when behind a reverse proxy. +This is useful for generating proper URLs, security decisions, and logging. + === WebSockets The proxy supports WebSocket by default. diff --git a/src/main/generated/io/vertx/httpproxy/ForwardedHeadersOptionsConverter.java b/src/main/generated/io/vertx/httpproxy/ForwardedHeadersOptionsConverter.java new file mode 100644 index 0000000..995ecd8 --- /dev/null +++ b/src/main/generated/io/vertx/httpproxy/ForwardedHeadersOptionsConverter.java @@ -0,0 +1,61 @@ +package io.vertx.httpproxy; + +import io.vertx.core.json.JsonObject; +import io.vertx.core.json.JsonArray; + +/** + * Converter and mapper for {@link io.vertx.httpproxy.ForwardedHeadersOptions}. + * NOTE: This class has been automatically generated from the {@link io.vertx.httpproxy.ForwardedHeadersOptions} original class using Vert.x codegen. + */ +public class ForwardedHeadersOptionsConverter { + + static void fromJson(Iterable> json, ForwardedHeadersOptions obj) { + for (java.util.Map.Entry member : json) { + switch (member.getKey()) { + case "enabled": + if (member.getValue() instanceof Boolean) { + obj.setEnabled((Boolean)member.getValue()); + } + break; + case "forwardFor": + if (member.getValue() instanceof Boolean) { + obj.setForwardFor((Boolean)member.getValue()); + } + break; + case "forwardProto": + if (member.getValue() instanceof Boolean) { + obj.setForwardProto((Boolean)member.getValue()); + } + break; + case "forwardHost": + if (member.getValue() instanceof Boolean) { + obj.setForwardHost((Boolean)member.getValue()); + } + break; + case "forwardPort": + if (member.getValue() instanceof Boolean) { + obj.setForwardPort((Boolean)member.getValue()); + } + break; + case "useRfc7239": + if (member.getValue() instanceof Boolean) { + obj.setUseRfc7239((Boolean)member.getValue()); + } + break; + } + } + } + + static void toJson(ForwardedHeadersOptions obj, JsonObject json) { + toJson(obj, json.getMap()); + } + + static void toJson(ForwardedHeadersOptions obj, java.util.Map json) { + json.put("enabled", obj.isEnabled()); + json.put("forwardFor", obj.isForwardFor()); + json.put("forwardProto", obj.isForwardProto()); + json.put("forwardHost", obj.isForwardHost()); + json.put("forwardPort", obj.isForwardPort()); + json.put("useRfc7239", obj.isUseRfc7239()); + } +} diff --git a/src/main/generated/io/vertx/httpproxy/ProxyOptionsConverter.java b/src/main/generated/io/vertx/httpproxy/ProxyOptionsConverter.java index 97f2ed7..33c57ea 100644 --- a/src/main/generated/io/vertx/httpproxy/ProxyOptionsConverter.java +++ b/src/main/generated/io/vertx/httpproxy/ProxyOptionsConverter.java @@ -22,6 +22,11 @@ static void fromJson(Iterable> json, ProxyOp obj.setSupportWebSocket((Boolean)member.getValue()); } break; + case "forwardedHeadersOptions": + if (member.getValue() instanceof JsonObject) { + obj.setForwardedHeadersOptions(new io.vertx.httpproxy.ForwardedHeadersOptions((io.vertx.core.json.JsonObject)member.getValue())); + } + break; } } } @@ -35,5 +40,8 @@ static void toJson(ProxyOptions obj, java.util.Map json) { json.put("cacheOptions", obj.getCacheOptions().toJson()); } json.put("supportWebSocket", obj.getSupportWebSocket()); + if (obj.getForwardedHeadersOptions() != null) { + json.put("forwardedHeadersOptions", obj.getForwardedHeadersOptions().toJson()); + } } } diff --git a/src/main/java/examples/HttpProxyExamples.java b/src/main/java/examples/HttpProxyExamples.java index 799bc78..a5f51c8 100644 --- a/src/main/java/examples/HttpProxyExamples.java +++ b/src/main/java/examples/HttpProxyExamples.java @@ -1,3 +1,14 @@ +/* + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ + package examples; import io.vertx.core.Future; @@ -214,4 +225,36 @@ public Future handleProxyRequest(ProxyContext context) { public void cacheConfig(Vertx vertx, HttpClient proxyClient) { HttpProxy proxy = HttpProxy.reverseProxy(new ProxyOptions().setCacheOptions(new CacheOptions()), proxyClient); } + + public void forwardedHeaders(Vertx vertx, HttpClient proxyClient) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true)); + + HttpProxy proxy = HttpProxy.reverseProxy(options, proxyClient); + proxy.origin(7070, "origin"); + } + + public void forwardedHeadersSelective(Vertx vertx, HttpClient proxyClient) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setForwardFor(true) + .setForwardProto(true) + .setForwardHost(true) + .setForwardPort(false)); // Don't forward port + + HttpProxy proxy = HttpProxy.reverseProxy(options, proxyClient); + proxy.origin(7070, "origin"); + } + + public void forwardedHeadersRfc7239(Vertx vertx, HttpClient proxyClient) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setUseRfc7239(true)); // Use RFC 7239 Forwarded header + + HttpProxy proxy = HttpProxy.reverseProxy(options, proxyClient); + proxy.origin(7070, "origin"); + } } diff --git a/src/main/java/io/vertx/httpproxy/ForwardedHeadersOptions.java b/src/main/java/io/vertx/httpproxy/ForwardedHeadersOptions.java new file mode 100644 index 0000000..14d4dcb --- /dev/null +++ b/src/main/java/io/vertx/httpproxy/ForwardedHeadersOptions.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.httpproxy; + +import io.vertx.codegen.annotations.DataObject; +import io.vertx.codegen.json.annotations.JsonGen; +import io.vertx.core.json.JsonObject; + +/** + * Options for configuring forwarded headers support in the proxy. + *

+ * These headers are used to preserve information about the original client request when proxying through one or more intermediaries. + *

+ * The proxy can add either {@code X-Forwarded-*} headers (de-facto standard) or the RFC 7239 {@code Forwarded} header. + */ +@DataObject +@JsonGen(publicConverter = false) +public class ForwardedHeadersOptions { + + /** + * Default enabled = {@code false} + */ + public static final boolean DEFAULT_ENABLED = false; + + /** + * Default forward for = {@code true} + */ + public static final boolean DEFAULT_FORWARD_FOR = true; + + /** + * Default forward proto = {@code true} + */ + public static final boolean DEFAULT_FORWARD_PROTO = true; + + /** + * Default forward host = {@code true} + */ + public static final boolean DEFAULT_FORWARD_HOST = true; + + /** + * Default forward port = {@code true} + */ + public static final boolean DEFAULT_FORWARD_PORT = true; + + /** + * Default use RFC 7239 = {@code false} + */ + public static final boolean DEFAULT_USE_RFC7239 = false; + + private boolean enabled = DEFAULT_ENABLED; + private boolean forwardFor = DEFAULT_FORWARD_FOR; + private boolean forwardProto = DEFAULT_FORWARD_PROTO; + private boolean forwardHost = DEFAULT_FORWARD_HOST; + private boolean forwardPort = DEFAULT_FORWARD_PORT; + private boolean useRfc7239 = DEFAULT_USE_RFC7239; + + /** + * Default constructor. + */ + public ForwardedHeadersOptions() { + } + + /** + * Copy constructor. + * + * @param other the options to copy + */ + public ForwardedHeadersOptions(ForwardedHeadersOptions other) { + this.enabled = other.isEnabled(); + this.forwardFor = other.isForwardFor(); + this.forwardProto = other.isForwardProto(); + this.forwardHost = other.isForwardHost(); + this.forwardPort = other.isForwardPort(); + this.useRfc7239 = other.isUseRfc7239(); + } + + /** + * Constructor to create an options from JSON. + * + * @param json the JSON + */ + public ForwardedHeadersOptions(JsonObject json) { + ForwardedHeadersOptionsConverter.fromJson(json, this); + } + + /** + * @return whether forwarded headers support is enabled + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Set whether forwarded headers support is enabled. + *

+ * When disabled, no forwarded headers will be added to proxied requests. + * + * @param enabled {@code true} to enable forwarded headers + * @return a reference to this, so the API can be used fluently + */ + public ForwardedHeadersOptions setEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * @return whether to forward client IP address + */ + public boolean isForwardFor() { + return forwardFor; + } + + /** + * Set whether to forward the client IP address. + *

+ * When enabled, adds the {@code X-Forwarded-For} header (or the 'for' parameter in RFC 7239 Forwarded header) with the client's IP address. + * If the header already exists, the client IP is appended to preserve the proxy chain. + * + * @param forwardFor {@code true} to forward client IP + * @return a reference to this, so the API can be used fluently + */ + public ForwardedHeadersOptions setForwardFor(boolean forwardFor) { + this.forwardFor = forwardFor; + return this; + } + + /** + * @return whether to forward the original protocol (http/https) + */ + public boolean isForwardProto() { + return forwardProto; + } + + /** + * Set whether to forward the original protocol (http/https). + *

+ * When enabled, adds the {@code X-Forwarded-Proto} header (or the 'proto' parameter in RFC 7239 Forwarded header) with the original request scheme. + * + * @param forwardProto {@code true} to forward protocol + * @return a reference to this, so the API can be used fluently + */ + public ForwardedHeadersOptions setForwardProto(boolean forwardProto) { + this.forwardProto = forwardProto; + return this; + } + + /** + * @return whether to forward the original host + */ + public boolean isForwardHost() { + return forwardHost; + } + + /** + * Set whether to forward the original host. + *

+ * When enabled, adds the {@code X-Forwarded-Host} header (or the 'host' parameter in RFC 7239 Forwarded header) with the original request host. + * This is only added if the host was not already set by {@link ProxyRequest#setAuthority(io.vertx.core.net.HostAndPort)}. + * + * @param forwardHost {@code true} to forward host + * @return a reference to this, so the API can be used fluently + */ + public ForwardedHeadersOptions setForwardHost(boolean forwardHost) { + this.forwardHost = forwardHost; + return this; + } + + /** + * @return whether to forward the original port + */ + public boolean isForwardPort() { + return forwardPort; + } + + /** + * Set whether to forward the original port. + *

+ * When enabled, adds the {@code X-Forwarded-Port} header with the original request port. + * This parameter is not included in RFC 7239 Forwarded header. + * + * @param forwardPort {@code true} to forward port + * @return a reference to this, so the API can be used fluently + */ + public ForwardedHeadersOptions setForwardPort(boolean forwardPort) { + this.forwardPort = forwardPort; + return this; + } + + /** + * @return whether to use RFC 7239 Forwarded header instead of {@code X-Forwarded-*} headers + */ + public boolean isUseRfc7239() { + return useRfc7239; + } + + /** + * Set whether to use RFC 7239 Forwarded header instead of {@code X-Forwarded-*} headers. + *

+ * When enabled, uses the standardized {@code Forwarded} header instead of the de-facto + * {@code X-Forwarded-For}, {@code X-Forwarded-Proto}, and {@code X-Forwarded-Host} headers. The {@code X-Forwarded-Port} + * header is not included in RFC 7239. + * + * @param useRfc7239 {@code true} to use RFC 7239 format + * @return a reference to this, so the API can be used fluently + */ + public ForwardedHeadersOptions setUseRfc7239(boolean useRfc7239) { + this.useRfc7239 = useRfc7239; + return this; + } + + @Override + public String toString() { + return toJson().toString(); + } + + /** + * Convert to JSON. + * + * @return the JSON + */ + public JsonObject toJson() { + JsonObject json = new JsonObject(); + ForwardedHeadersOptionsConverter.toJson(this, json); + return json; + } +} diff --git a/src/main/java/io/vertx/httpproxy/ProxyOptions.java b/src/main/java/io/vertx/httpproxy/ProxyOptions.java index bae6bed..654e21b 100644 --- a/src/main/java/io/vertx/httpproxy/ProxyOptions.java +++ b/src/main/java/io/vertx/httpproxy/ProxyOptions.java @@ -1,3 +1,14 @@ +/* + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ + package io.vertx.httpproxy; import io.vertx.codegen.annotations.DataObject; @@ -19,6 +30,7 @@ public class ProxyOptions { private CacheOptions cacheOptions; private boolean supportWebSocket; + private ForwardedHeadersOptions forwardedHeadersOptions; public ProxyOptions(JsonObject json) { ProxyOptionsConverter.fromJson(json, this); @@ -66,6 +78,28 @@ public ProxyOptions setSupportWebSocket(boolean supportWebSocket) { return this; } + /** + * @return the forwarded headers options + */ + public ForwardedHeadersOptions getForwardedHeadersOptions() { + return forwardedHeadersOptions; + } + + /** + * Set the forwarded headers options that configures how the proxy handles + * X-Forwarded-* or RFC 7239 Forwarded headers. + *

+ * {@code null} forwarded headers options disables forwarded headers support, + * by default forwarded headers support is disabled. + * + * @param forwardedHeadersOptions the forwarded headers options + * @return a reference to this, so the API can be used fluently + */ + public ProxyOptions setForwardedHeadersOptions(ForwardedHeadersOptions forwardedHeadersOptions) { + this.forwardedHeadersOptions = forwardedHeadersOptions; + return this; + } + @Override public String toString() { return toJson().toString(); diff --git a/src/main/java/io/vertx/httpproxy/ProxyRequest.java b/src/main/java/io/vertx/httpproxy/ProxyRequest.java index 30d96d5..9dbef2e 100644 --- a/src/main/java/io/vertx/httpproxy/ProxyRequest.java +++ b/src/main/java/io/vertx/httpproxy/ProxyRequest.java @@ -38,8 +38,15 @@ public interface ProxyRequest { * @return a reference to this, so the API can be used fluently */ static ProxyRequest reverseProxy(HttpServerRequest proxiedRequest) { + return reverseProxy(proxiedRequest, null); + } + + /** + * Like {@link #reverseProxy(HttpServerRequest)} but using specific For + */ + static ProxyRequest reverseProxy(HttpServerRequest proxiedRequest, ForwardedHeadersOptions forwardedHeadersOptions) { proxiedRequest.pause(); - return new ProxiedRequest(proxiedRequest); + return new ProxiedRequest(proxiedRequest, forwardedHeadersOptions); } /** diff --git a/src/main/java/io/vertx/httpproxy/impl/ProxiedRequest.java b/src/main/java/io/vertx/httpproxy/impl/ProxiedRequest.java index 83882f6..a949d35 100644 --- a/src/main/java/io/vertx/httpproxy/impl/ProxiedRequest.java +++ b/src/main/java/io/vertx/httpproxy/impl/ProxiedRequest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -17,8 +17,10 @@ import io.vertx.core.internal.ContextInternal; import io.vertx.core.internal.http.HttpServerRequestInternal; import io.vertx.core.net.HostAndPort; +import io.vertx.core.net.SocketAddress; import io.vertx.core.streams.Pipe; import io.vertx.httpproxy.Body; +import io.vertx.httpproxy.ForwardedHeadersOptions; import io.vertx.httpproxy.ProxyRequest; import io.vertx.httpproxy.ProxyResponse; @@ -30,6 +32,17 @@ public class ProxiedRequest implements ProxyRequest { private static final CharSequence X_FORWARDED_HOST = HttpHeaders.createOptimized("x-forwarded-host"); + private static final CharSequence X_FORWARDED_FOR = HttpHeaders.createOptimized("x-forwarded-for"); + private static final CharSequence X_FORWARDED_PROTO = HttpHeaders.createOptimized("x-forwarded-proto"); + private static final CharSequence X_FORWARDED_PORT = HttpHeaders.createOptimized("x-forwarded-port"); + private static final CharSequence FORWARDED = HttpHeaders.createOptimized("forwarded"); + + // Forwarded headers bit flags + private static final int FLAG_FORWARD_FOR = 1; // bit 0 + private static final int FLAG_FORWARD_PROTO = 2; // bit 1 + private static final int FLAG_FORWARD_HOST = 4; // bit 2 + private static final int FLAG_FORWARD_PORT = 8; // bit 3 + private static final int FLAG_USE_RFC7239 = 16; // bit 4 private static final MultiMap HOP_BY_HOP_HEADERS = MultiMap.caseInsensitiveMultiMap() .add(CONNECTION, "whatever") @@ -51,9 +64,9 @@ public class ProxiedRequest implements ProxyRequest { private final MultiMap headers; HttpClientRequest request; private final HttpServerRequest proxiedRequest; + private final int forwardedHeadersFlags; - public ProxiedRequest(HttpServerRequest proxiedRequest) { - + public ProxiedRequest(HttpServerRequest proxiedRequest, ForwardedHeadersOptions forwardedHeadersOptions) { // Determine content length long contentLength = -1L; String contentLengthHeader = proxiedRequest.getHeader(CONTENT_LENGTH); @@ -77,6 +90,32 @@ public ProxiedRequest(HttpServerRequest proxiedRequest) { this.proxiedRequest = proxiedRequest; this.context = ((HttpServerRequestInternal) proxiedRequest).context(); this.authority = null; // null is used as a signal to indicate an unchanged authority + + // Convert forwarded headers options to bit flags for efficient checking + this.forwardedHeadersFlags = buildForwardedHeadersFlags(forwardedHeadersOptions); + } + + private static int buildForwardedHeadersFlags(ForwardedHeadersOptions options) { + if (options == null || !options.isEnabled()) { + return 0; + } + int flags = 0; + if (options.isForwardFor()) { + flags |= FLAG_FORWARD_FOR; + } + if (options.isForwardProto()) { + flags |= FLAG_FORWARD_PROTO; + } + if (options.isForwardHost()) { + flags |= FLAG_FORWARD_HOST; + } + if (options.isForwardPort()) { + flags |= FLAG_FORWARD_PORT; + } + if (options.isUseRfc7239()) { + flags |= FLAG_USE_RFC7239; + } + return flags; } @Override @@ -179,6 +218,11 @@ Future sendRequest() { } } + // Add forwarded headers if configured + if (forwardedHeadersFlags != 0) { + addForwardedHeaders(request); + } + if (body == null) { if (proxiedRequest.headers().contains(CONTENT_LENGTH)) { request.putHeader(CONTENT_LENGTH, "0"); @@ -209,6 +253,139 @@ Future sendRequest() { }); } + private void addForwardedHeaders(HttpClientRequest request) { + if ((forwardedHeadersFlags & FLAG_USE_RFC7239) != 0) { + addRfc7239ForwardedHeader(request); + } else { + addXForwardedHeaders(request); + } + } + + private void addRfc7239ForwardedHeader(HttpClientRequest request) { + int capacity = estimateRfc7239Capacity(forwardedHeadersFlags); + StringBuilder forwarded = new StringBuilder(capacity); + if ((forwardedHeadersFlags & FLAG_FORWARD_FOR) != 0) { + appendRfc7239For(forwarded); + } + if ((forwardedHeadersFlags & FLAG_FORWARD_PROTO) != 0) { + appendRfc7239Proto(forwarded); + } + if ((forwardedHeadersFlags & FLAG_FORWARD_HOST) != 0) { + appendRfc7239Host(forwarded); + } + // If we reach here with non-zero flags, at least one component should be added + appendHeader(request, FORWARDED, forwarded.toString()); + } + + private static int estimateRfc7239Capacity(int flags) { + // Capacity estimates to avoid StringBuilder resizing: + // for=IPv6(39) + quotes(2) + prefix(4) ≈ 50 + // proto=https(5) + prefix(6) ≈ 15 + // host=domain(253) + port(6) + quotes(2) + prefix(5) ≈ 260 + int capacity = 0; + if ((flags & FLAG_FORWARD_FOR) != 0) { + capacity += 50; + } + if ((flags & FLAG_FORWARD_PROTO) != 0) { + capacity += 15; + } + if ((flags & FLAG_FORWARD_HOST) != 0) { + capacity += 260; + } + return capacity; + } + + private void appendRfc7239For(StringBuilder forwarded) { + String clientIp = getClientIp(); + if (clientIp != null) { + forwarded.append("for=").append(quoteIfNeeded(clientIp)); + } + } + + private void appendRfc7239Proto(StringBuilder forwarded) { + String proto = proxiedRequest.scheme(); + if (proto != null) { + if (forwarded.length() > 0) forwarded.append(";"); + forwarded.append("proto=").append(proto); + } + } + + private void appendRfc7239Host(StringBuilder forwarded) { + HostAndPort host = proxiedRequest.authority(); + if (host != null) { + if (forwarded.length() > 0) forwarded.append(";"); + forwarded.append("host=").append(quoteIfNeeded(host.toString())); + } + } + + private void addXForwardedHeaders(HttpClientRequest request) { + if ((forwardedHeadersFlags & FLAG_FORWARD_FOR) != 0) { + addXForwardedFor(request); + } + if ((forwardedHeadersFlags & FLAG_FORWARD_PROTO) != 0) { + addXForwardedProto(request); + } + if ((forwardedHeadersFlags & FLAG_FORWARD_HOST) != 0) { + addXForwardedHost(request); + } + if ((forwardedHeadersFlags & FLAG_FORWARD_PORT) != 0) { + addXForwardedPort(request); + } + } + + private void addXForwardedFor(HttpClientRequest request) { + String clientIp = getClientIp(); + if (clientIp != null) { + appendHeader(request, X_FORWARDED_FOR, clientIp); + } + } + + private void addXForwardedProto(HttpClientRequest request) { + String proto = proxiedRequest.scheme(); + if (proto != null) { + request.putHeader(X_FORWARDED_PROTO, proto); + } + } + + private void addXForwardedHost(HttpClientRequest request) { + // Only add if not already set by setAuthority() logic + if (!request.headers().contains(X_FORWARDED_HOST)) { + HostAndPort host = proxiedRequest.authority(); + if (host != null) { + request.putHeader(X_FORWARDED_HOST, host.host()); + } + } + } + + private void addXForwardedPort(HttpClientRequest request) { + HostAndPort host = proxiedRequest.authority(); + if (host != null) { + request.putHeader(X_FORWARDED_PORT, String.valueOf(host.port())); + } + } + + private String getClientIp() { + SocketAddress remoteAddress = proxiedRequest.remoteAddress(); + return remoteAddress != null ? remoteAddress.hostAddress() : null; + } + + private void appendHeader(HttpClientRequest request, CharSequence name, String value) { + String existing = request.headers().get(name); + if (existing != null) { + request.putHeader(name, existing + ", " + value); + } else { + request.putHeader(name, value); + } + } + + private String quoteIfNeeded(String value) { + // Quote IPv6 addresses and values containing special characters + if (value.contains(":") || value.contains(";") || value.contains(",")) { + return "\"" + value + "\""; + } + return value; + } + private static boolean equals(HostAndPort hp1, HostAndPort hp2) { if (hp1 == null || hp2 == null) { return false; diff --git a/src/main/java/io/vertx/httpproxy/impl/ReverseProxy.java b/src/main/java/io/vertx/httpproxy/impl/ReverseProxy.java index cda186f..27299ce 100644 --- a/src/main/java/io/vertx/httpproxy/impl/ReverseProxy.java +++ b/src/main/java/io/vertx/httpproxy/impl/ReverseProxy.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at @@ -27,15 +27,14 @@ import java.util.*; -import static io.vertx.core.http.HttpHeaders.CONNECTION; -import static io.vertx.core.http.HttpHeaders.HOST; -import static io.vertx.core.http.HttpHeaders.UPGRADE; +import static io.vertx.core.http.HttpHeaders.*; public class ReverseProxy implements HttpProxy { private final static Logger log = LoggerFactory.getLogger(ReverseProxy.class); private final HttpClient client; private final boolean supportWebSocket; + private final ForwardedHeadersOptions forwardedHeadersOptions; private OriginRequestProvider originRequestProvider = (pc) -> Future.failedFuture("No origin available"); private final List interceptors = new ArrayList<>(); @@ -47,6 +46,7 @@ public ReverseProxy(ProxyOptions options, HttpClient client) { } this.client = client; this.supportWebSocket = options.getSupportWebSocket(); + this.forwardedHeadersOptions = options.getForwardedHeadersOptions(); } public Cache newCache(CacheOptions options, Vertx vertx) { @@ -73,7 +73,7 @@ public HttpProxy addInterceptor(ProxyInterceptor interceptor, boolean supportsWe @Override public void handle(HttpServerRequest request) { - ProxyRequest proxyRequest = ProxyRequest.reverseProxy(request); + ProxyRequest proxyRequest = ProxyRequest.reverseProxy(request, forwardedHeadersOptions); // Encoding sanity check Boolean chunked = HttpUtils.isChunked(request.headers()); diff --git a/src/test/java/io/vertx/tests/ForwardedHeadersTest.java b/src/test/java/io/vertx/tests/ForwardedHeadersTest.java new file mode 100644 index 0000000..4744888 --- /dev/null +++ b/src/test/java/io/vertx/tests/ForwardedHeadersTest.java @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.tests; + +import io.vertx.core.Handler; +import io.vertx.core.http.*; +import io.vertx.core.net.PemKeyCertOptions; +import io.vertx.core.net.SocketAddress; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import io.vertx.httpproxy.ForwardedHeadersOptions; +import io.vertx.httpproxy.ProxyOptions; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; +import java.util.function.Consumer; + +@RunWith(VertxUnitRunner.class) +public class ForwardedHeadersTest extends TestBase { + + public ForwardedHeadersTest() { + super(new ProxyOptions()); + } + + @Test + public void testForwardedHeadersDisabledByDefault(TestContext ctx) { + runForwardedHeadersTest(ctx, null, null, null, req -> { + ctx.assertNull(req.getHeader("X-Forwarded-For")); + ctx.assertNull(req.getHeader("X-Forwarded-Proto")); + ctx.assertNull(req.getHeader("X-Forwarded-Host")); + ctx.assertNull(req.getHeader("X-Forwarded-Port")); + ctx.assertNull(req.getHeader("Forwarded")); + }); + } + + @Test + public void testXForwardedForEnabled(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setForwardFor(true) + .setForwardProto(false) + .setForwardHost(false) + .setForwardPort(false) + ); + + runForwardedHeadersTest(ctx, options, null, null, req -> { + String xForwardedFor = req.getHeader("X-Forwarded-For"); + ctx.assertNotNull(xForwardedFor); + ctx.assertTrue(xForwardedFor.contains("127.0.0.1") || xForwardedFor.contains("0:0:0:0:0:0:0:1")); + ctx.assertNull(req.getHeader("X-Forwarded-Proto")); + ctx.assertNull(req.getHeader("X-Forwarded-Host")); + ctx.assertNull(req.getHeader("X-Forwarded-Port")); + }); + } + + @Test + public void testXForwardedProtoEnabled(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setForwardFor(false) + .setForwardProto(true) + .setForwardHost(false) + .setForwardPort(false) + ); + + runForwardedHeadersTest(ctx, options, null, null, req -> { + String xForwardedProto = req.getHeader("X-Forwarded-Proto"); + ctx.assertNotNull(xForwardedProto); + ctx.assertEquals("http", xForwardedProto); + ctx.assertNull(req.getHeader("X-Forwarded-For")); + }); + } + + @Test + public void testXForwardedHostEnabled(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setForwardFor(false) + .setForwardProto(false) + .setForwardHost(true) + .setForwardPort(false) + ); + + runForwardedHeadersTest(ctx, options, null, null, req -> { + String xForwardedHost = req.getHeader("X-Forwarded-Host"); + ctx.assertNotNull(xForwardedHost); + ctx.assertEquals("localhost", xForwardedHost); + }); + } + + @Test + public void testXForwardedPortEnabled(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setForwardFor(false) + .setForwardProto(false) + .setForwardHost(false) + .setForwardPort(true) + ); + + runForwardedHeadersTest(ctx, options, null, null, req -> { + String xForwardedPort = req.getHeader("X-Forwarded-Port"); + ctx.assertNotNull(xForwardedPort); + ctx.assertEquals("8080", xForwardedPort); + }); + } + + @Test + public void testAllXForwardedHeadersEnabled(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setForwardFor(true) + .setForwardProto(true) + .setForwardHost(true) + .setForwardPort(true) + ); + + runForwardedHeadersTest(ctx, options, null, null, req -> { + String xForwardedFor = req.getHeader("X-Forwarded-For"); + ctx.assertNotNull(xForwardedFor); + ctx.assertTrue(xForwardedFor.contains("127.0.0.1") || xForwardedFor.contains("0:0:0:0:0:0:0:1")); + + String xForwardedProto = req.getHeader("X-Forwarded-Proto"); + ctx.assertNotNull(xForwardedProto); + ctx.assertEquals("http", xForwardedProto); + + String xForwardedHost = req.getHeader("X-Forwarded-Host"); + ctx.assertNotNull(xForwardedHost); + ctx.assertEquals("localhost", xForwardedHost); + + String xForwardedPort = req.getHeader("X-Forwarded-Port"); + ctx.assertNotNull(xForwardedPort); + ctx.assertEquals("8080", xForwardedPort); + }); + } + + @Test + public void testXForwardedForChainPreservation(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setForwardFor(true) + ); + + runForwardedHeadersTest(ctx, options, null, req -> { + req.putHeader("X-Forwarded-For", "192.168.1.100"); + }, req -> { + String xForwardedFor = req.getHeader("X-Forwarded-For"); + ctx.assertNotNull(xForwardedFor); + // Should contain both the original client and the proxy's append + List parts = List.of(xForwardedFor.split(",\\s*")); + ctx.assertTrue(parts.size() >= 2); + ctx.assertEquals("192.168.1.100", parts.get(0)); + ctx.assertTrue(parts.get(1).contains("127.0.0.1") || parts.get(1).contains("0:0:0:0:0:0:0:1")); + }); + } + + @Test + public void testRfc7239ForwardedHeader(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setUseRfc7239(true) + .setForwardFor(true) + .setForwardProto(true) + .setForwardHost(true) + ); + + // Should NOT have X-Forwarded-* headers when using RFC 7239 + runForwardedHeadersTest(ctx, options, null, null, req -> { + String forwarded = req.getHeader("Forwarded"); + ctx.assertNotNull(forwarded); + ctx.assertTrue(forwarded.contains("for=")); + ctx.assertTrue(forwarded.contains("proto=http")); + ctx.assertTrue(forwarded.contains("host=")); + + // Should NOT have X-Forwarded-* headers when using RFC 7239 + ctx.assertNull(req.getHeader("X-Forwarded-For")); + ctx.assertNull(req.getHeader("X-Forwarded-Proto")); + }); + } + + @Test + public void testRfc7239ForwardedHeaderChainPreservation(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setUseRfc7239(true) + .setForwardFor(true) + .setForwardProto(true) + ); + + runForwardedHeadersTest(ctx, options, null, req -> { + req.putHeader("Forwarded", "for=192.0.2.60;proto=https"); + }, req -> { + String forwarded = req.getHeader("Forwarded"); + ctx.assertNotNull(forwarded); + // Should contain both the original and the appended entry + List parts = List.of(forwarded.split(",\\s*")); + ctx.assertEquals(2, parts.size()); + ctx.assertTrue(parts.get(0).contains("for=192.0.2.60")); + ctx.assertTrue(parts.get(1).contains("for=")); + ctx.assertTrue(parts.get(1).contains("proto=http")); + }); + } + + @Test + public void testRfc7239ForOnlyEnabled(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setUseRfc7239(true) + .setForwardFor(true) + .setForwardProto(false) + .setForwardHost(false) + ); + + runForwardedHeadersTest(ctx, options, null, null, req -> { + String forwarded = req.getHeader("Forwarded"); + ctx.assertNotNull(forwarded); + ctx.assertTrue(forwarded.contains("for=")); + ctx.assertFalse(forwarded.contains("proto=")); + ctx.assertFalse(forwarded.contains("host=")); + }); + } + + @Test + public void testForwardedHeadersWithHttps(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(true) + .setForwardProto(true) + ); + + // Load test certificates from classpath (see src/test/resources/SSL_TEST_CERTIFICATES.txt) + PemKeyCertOptions pemOptions = new PemKeyCertOptions() + .setCertPath("server.cert.pem") + .setKeyPath("server.key.pem"); + + HttpServerOptions httpsServerOptions = new HttpServerOptions() + .setPort(8443) + .setHost("localhost") + .setSsl(true) + .setKeyCertOptions(pemOptions); + + runForwardedHeadersTest(ctx, options, httpsServerOptions, null, req -> { + String xForwardedProto = req.getHeader("X-Forwarded-Proto"); + ctx.assertNotNull(xForwardedProto); + ctx.assertEquals("https", xForwardedProto); + }); + } + + @Test + public void testEnabledFalseDoesNotAddHeaders(TestContext ctx) { + ProxyOptions options = new ProxyOptions() + .setForwardedHeadersOptions(new ForwardedHeadersOptions() + .setEnabled(false) // Explicitly disabled + .setForwardFor(true) + .setForwardProto(true) + ); + + runForwardedHeadersTest(ctx, options, null, null, req -> { + ctx.assertNull(req.getHeader("X-Forwarded-For")); + ctx.assertNull(req.getHeader("X-Forwarded-Proto")); + }); + } + + private void runForwardedHeadersTest(TestContext ctx, ProxyOptions options, HttpServerOptions serverOpts, Consumer requestCustomizer, Handler backendHandler) { + Async latch = ctx.async(); + + SocketAddress backend = startHttpBackend(ctx, 8081, req -> { + backendHandler.handle(req); + req.response().end("OK"); + }); + + if (options != null) { + proxyOptions = options; + } + if (serverOpts != null) { + serverOptions = serverOpts; + } + startProxy(backend); + + // Determine client settings based on server options + boolean isHttps = serverOpts != null && serverOpts.isSsl(); + int port = isHttps ? 8443 : 8080; + HttpClient client = isHttps + ? vertx.createHttpClient(new HttpClientOptions().setSsl(true).setTrustAll(true).setVerifyHost(false)) + : vertx.createHttpClient(); + + client + .request(HttpMethod.GET, port, "localhost", "/") + .compose(req -> { + if (requestCustomizer != null) { + requestCustomizer.accept(req); + } + return req.send().compose(HttpClientResponse::body); + }) + .onComplete(ctx.asyncAssertSuccess(body -> { + ctx.assertEquals("OK", body.toString()); + latch.complete(); + })); + + latch.await(); + } +} diff --git a/src/test/resources/SSL_TEST_CERTIFICATES.txt b/src/test/resources/SSL_TEST_CERTIFICATES.txt new file mode 100644 index 0000000..ca817bc --- /dev/null +++ b/src/test/resources/SSL_TEST_CERTIFICATES.txt @@ -0,0 +1,49 @@ +SSL Test Certificates for HTTPS Testing +========================================= + +This directory contains self-signed SSL certificates used for testing HTTPS functionality +in the vertx-http-proxy tests. + +Files: +------ +- server.cert.pem: Self-signed X.509 certificate for localhost +- server.key.pem: RSA private key for the certificate + +⚠️ WARNING: These certificates are for TESTING ONLY. Never use them in production! + +How to Regenerate: +------------------ + +If you need to regenerate these test certificates (e.g., if they expire), use the +following OpenSSL command: + + openssl req -x509 \ + -newkey rsa:2048 \ + -keyout server.key.pem \ + -out server.cert.pem \ + -days 3650 \ + -nodes \ + -subj "/CN=localhost" + +This will create: +- A 2048-bit RSA private key (server.key.pem) +- A self-signed certificate valid for 3650 days / 10 years (server.cert.pem) +- Certificate issued for CN=localhost +- No passphrase protection (-nodes flag) + +Certificate Details: +-------------------- +- Common Name (CN): localhost +- Key Size: 2048-bit RSA +- Validity: 10 years (3650 days) from generation date +- No passphrase protection + +Usage in Tests: +--------------- +These certificates are loaded in ForwardedHeadersTest.java using PemKeyCertOptions: + + PemKeyCertOptions pemOptions = new PemKeyCertOptions() + .setCertPath("server.cert.pem") + .setKeyPath("server.key.pem"); + +The files are loaded from the classpath (src/test/resources). diff --git a/src/test/resources/server.cert.pem b/src/test/resources/server.cert.pem new file mode 100644 index 0000000..abc2374 --- /dev/null +++ b/src/test/resources/server.cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUNuAiBe69Mh37+BiC8ZRR3PqKb8QwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDMyNDE2MzMxOVoXDTM2MDMy +MTE2MzMxOVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA8Jy1xq6OUoo+AQ239nMZL1/+ZQ65ym8qi9Whfez8QY7m +hsOoMTmPAzlLFpt1dQ0mhfd+b/zIjqLnX1OIJ/xd+PQrkKtDJSztuZyodDeieJ0x +lB3gKqe+/ok9N5uNa14uYMe+uc7WX4F0iBTVk+Xszr8GmIIqBYi7pwEu1AeWEs1H +T6QCgCeJsvMIgqwGB2g3pXc1KI5L5HCnmvu5SKjdexw8MCVKIfEy5PYqS/9Zc1jW +7UVf6BOuobKyzRro9uDHqpSNOjtPW+5sjz/cA6fnB8HDj3ROWBUmE65ET2AC9FBD +uf9+Pokql4lCjDQgXKvdBYxqSilIHD+Lpt4ExNNnMQIDAQABo1MwUTAdBgNVHQ4E +FgQUEN6fbQMwP0b6WRaqtZ25Gj3FRy0wHwYDVR0jBBgwFoAUEN6fbQMwP0b6WRaq +tZ25Gj3FRy0wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAKYoU +Gr+QSKjO7oDpqupLFhiTV7YYiuuzr7VT4FNffzle5F9Vd0krPs6rDdpFaDMwdzPc +WoD75K0FfypVQZKU7f3JxZo/3RNtP+VcoDVGlm2OPzyFw8dHKo7MBpd5Hn4eEoQt +MWJbFxMb89JAdbGCg9+UzFy93v9A2odS9ghlRKRBtazM5CodaofD2HAWRxLgJSnR +KpTMKxoBhHni9KssMzDGwtYqnfAkXWiLkYr9BpmguFeV7C+Gudz6rSqZISlxgF2V +KJBGNzVWKbu9SbEF30w+5WAhy8HbR/jrzdxMkj5H1v9awgAykv6LiQtzVKR4wNDm +ig8F7QUmhjIKF7V3BA== +-----END CERTIFICATE----- diff --git a/src/test/resources/server.key.pem b/src/test/resources/server.key.pem new file mode 100644 index 0000000..25210fa --- /dev/null +++ b/src/test/resources/server.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDwnLXGro5Sij4B +Dbf2cxkvX/5lDrnKbyqL1aF97PxBjuaGw6gxOY8DOUsWm3V1DSaF935v/MiOoudf +U4gn/F349CuQq0MlLO25nKh0N6J4nTGUHeAqp77+iT03m41rXi5gx765ztZfgXSI +FNWT5ezOvwaYgioFiLunAS7UB5YSzUdPpAKAJ4my8wiCrAYHaDeldzUojkvkcKea ++7lIqN17HDwwJUoh8TLk9ipL/1lzWNbtRV/oE66hsrLNGuj24MeqlI06O09b7myP +P9wDp+cHwcOPdE5YFSYTrkRPYAL0UEO5/34+iSqXiUKMNCBcq90FjGpKKUgcP4um +3gTE02cxAgMBAAECggEABJFxGQ3Q2WvS/Ami47yxLndT2N7T864zIFcEuyOP8X2x +Tj8yNwcI+k5w6MO4UenXQKVfguhJdX3Nxz9KfznZHeQi3JsHyhGljMfW4ef5f3E6 +kv2TNzQamfIIlcDwCSkPV9qK+jOdWdBXIktE98seeRli2mvkONks+OF6nl9rf71Z +DwJPXMc65zZdxT2SH81E2I7YJetf8/NwpkR1TT8IZEIERbkf6o1QtpM7D4j3CM/K +XYmwmTu+wMmIuviOTjQ22HRENmdrUgKiFE44EHafFBCpGfsy/QzSboye2VfnUx7P +6sr6KEv9ra1Iotp6kL0Tw0UWYD46vgS2w3jgCD82vQKBgQD/Ndd0g6yT5XP2gF7C +SX8R+muZWYMzHaxvCt8qkF/rXSd4YpqvOnVIPFpCjF4sFng7PyBdYHROanVerazD +2RWaYT6IaBZMumJhBs0q5t8lhsTqEPopZiQO3kvf+b8ZB6AxHSPpMq4pMmBFpyVX +WTy5t7VnL8jDerRc9l4nmTYGXQKBgQDxW04MCgphKaqxLu4VUh8gC7T/l8uzou/r +4TR2F1KJOrpgyh3JugSUoLMGF8li5x+iCtJtEh9AAoJng2piem1Lfx2cKet1Pw42 +cZmn+Hux+6qRdCDA89Uc5J47z1O5ajdlC0MvjpOq/6HMq6F/xj8ls5zBgTqCa3D+ +m//JMBcu5QKBgQCTjItuf5pNP4GV6I3ggiGHQWLoHApwiR1ibcSslR//sbHaNhf8 +ay3Xf50nkIP5tVv51PtfQR69H2uQO5UA/gcxZDuFHbiWz8OFGSuRPD2TMqGyOfKH +8Ne0CO2RJpFHZVNGUfSrJHPwuYFTg9lg8OTc8stcRxIpgk242W+CMWA8uQKBgQC2 +b6/FBenmm9o8aB7q2tJQJMlB0fnV5Tm5cNd41BQ9SrkK5HwzuXow5m+sEVrWeaG9 +mLSKYYJhngP4PquxmJz4zjMRkdY7H9icaq4c+4R0eqjpnYAMmcLjPiQlTYgZxSHu +LN8yGXGhde1Vif5fWPjuhJ/e3NHfN1uH+V8VMlazyQKBgQDdaTqWunEIjJAW/7FN +tfLR2aFIdjd+rEpFNNLab8wQzH5sntzCrQH+KHG7Wlaut8Xla/769QREnqVCnltb +MSRk2zym1w1vCHeDc+oyYbGuQXvSAi8AbYBcRYFcI34UIyh9ciGL1ZDSze0pl9fP +Vm4di2lr4bAPxEGScMKiyVPjOg== +-----END PRIVATE KEY-----