Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/main/java/io/nats/client/Options.java
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,12 @@ public class Options {
*/
public static final String PROP_FAST_FALLBACK = PFX + "fast.fallback";

/**
* Property used to enable InetSocketAddress.createUnresolved for proxied connections.
* {@link Builder#enableInetAddressCreateUnresolved() enableInetAddressCreateUnresolved}.
*/
public static final String PROP_ENABLE_INET_ADDRESS_CREATE_UNRESOLVED = PFX + "inet.address.create.unresolved";

// ----------------------------------------------------------------------------------------------------
// PROTOCOL CONNECT OPTION CONSTANTS
// ----------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -714,6 +720,7 @@ public class Options {
private final List<java.util.function.Consumer<HttpRequest>> httpRequestInterceptors;
private final Proxy proxy;
private final boolean enableFastFallback;
private final boolean enableInetAddressCreateUnresolved;

static class DefaultThreadFactory implements ThreadFactory {
final String name;
Expand Down Expand Up @@ -865,6 +872,7 @@ public static class Builder {
private String tlsAlgorithm = DEFAULT_TLS_ALGORITHM;
private String credentialPath;
private boolean enableFastFallback = false;
private boolean enableInetAddressCreateUnresolved = false;

/**
* Constructs a new Builder with the default values.
Expand Down Expand Up @@ -984,6 +992,7 @@ public Builder properties(Properties props) {
booleanProperty(props, PROP_USE_DISPATCHER_WITH_EXECUTOR, b -> this.useDispatcherWithExecutor = b);
booleanProperty(props, PROP_FORCE_FLUSH_ON_REQUEST, b -> this.forceFlushOnRequest = b);
booleanProperty(props, PROP_FAST_FALLBACK, b -> this.enableFastFallback = b);
booleanProperty(props, PROP_ENABLE_INET_ADDRESS_CREATE_UNRESOLVED, b -> this.enableInetAddressCreateUnresolved = b);

classnameProperty(props, PROP_SERVERS_POOL_IMPLEMENTATION_CLASS, o -> this.serverPool = (ServerPool) o);
classnameProperty(props, PROP_DISPATCHER_FACTORY_CLASS, o -> this.dispatcherFactory = (DispatcherFactory) o);
Expand Down Expand Up @@ -1889,6 +1898,16 @@ public Builder enableFastFallback() {
return this;
}

/**
* Whether to enable InetSocketAddress.createUnresolved for proxied connections.
* This is useful for backward compatibility and when hostname resolution should be deferred.
* @return the Builder for chaining
*/
public Builder enableInetAddressCreateUnresolved() {
this.enableInetAddressCreateUnresolved = true;
return this;
}

/**
* Build an Options object from this Builder.
*
Expand Down Expand Up @@ -2114,6 +2133,7 @@ public Builder(Options o) {
this.serverPool = o.serverPool;
this.dispatcherFactory = o.dispatcherFactory;
this.enableFastFallback = o.enableFastFallback;
this.enableInetAddressCreateUnresolved = o.enableInetAddressCreateUnresolved;
}
}

Expand Down Expand Up @@ -2186,6 +2206,7 @@ private Options(Builder b) {
this.serverPool = b.serverPool;
this.dispatcherFactory = b.dispatcherFactory;
this.enableFastFallback = b.enableFastFallback;
this.enableInetAddressCreateUnresolved = b.enableInetAddressCreateUnresolved;
}

// ----------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -2729,6 +2750,14 @@ public boolean isEnableFastFallback() {
return enableFastFallback;
}

/**
* Whether InetSocketAddress.createUnresolved is enabled for proxied connections
* @return the flag
*/
public boolean isEnableInetAddressCreateUnresolved() {
return enableInetAddressCreateUnresolved;
}

public URI createURIForServer(String serverURI) throws URISyntaxException {
return new NatsUri(serverURI).getUri();
}
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/io/nats/client/impl/SocketDataPort.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ public void connect(@NonNull NatsConnection conn, @NonNull NatsUri nuri, long ti
socket = connectToFastestIp(options, host, port, (int) timeout);
} else {
socket = createSocket(options);
socket.connect(new InetSocketAddress(host, port), (int) timeout);
InetSocketAddress inetSocketAddress;
if (options.isEnableInetAddressCreateUnresolved() && !nuri.hostIsIpAddress()) {
inetSocketAddress = InetSocketAddress.createUnresolved(host, port);
} else {
inetSocketAddress = new InetSocketAddress(host, port);
}
socket.connect(inetSocketAddress, (int) timeout);
}

if (options.getSocketReadTimeoutMillis() > 0) {
Expand Down
249 changes: 249 additions & 0 deletions src/test/java/io/nats/client/impl/SocketDataPortProxyHostnameTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
// Copyright 2015-2018 The NATS Authors
// Licensed 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:
//
// http://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 io.nats.client.impl;

import io.nats.client.Options;
import io.nats.client.support.NatsUri;
import io.nats.client.utils.TestBase;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static org.junit.jupiter.api.Assertions.*;

/**
* Test for proxy hostname resolution bug fix.
*
* When a proxy is configured with domain name whitelisting, the client should NOT
* resolve the hostname to an IP address before connecting. Instead, it should pass
* the hostname as-is to the proxy so the proxy can enforce domain whitelisting.
*/
public class SocketDataPortProxyHostnameTest extends TestBase {

/**
* Mock proxy that tracks whether it received the CONNECT request with
* a domain name or an IP address.
*/
static class WhitelistingProxyServer implements Runnable {
private final ServerSocket serverSocket;
private volatile String receivedHost;
private final ExecutorService executor;

WhitelistingProxyServer(ExecutorService executor) throws IOException {
this.executor = executor;
// Bind to localhost on ephemeral port
this.serverSocket = new ServerSocket(0, 10, InetAddress.getLoopbackAddress());
}

public int getPort() {
return serverSocket.getLocalPort();
}

public String getReceivedHost() {
return receivedHost;
}

@Override
public void run() {
try {
while (true) {
Socket clientSocket = serverSocket.accept();
executor.submit(() -> handleClientConnection(clientSocket));
}
} catch (IOException e) {
// Expected when shutting down
}
}

private void handleClientConnection(Socket clientSocket) {
try (Socket client = clientSocket;
InputStream in = client.getInputStream();
OutputStream out = client.getOutputStream()) {

// Read CONNECT request line
String connectRequest = readLine(in);

if (connectRequest != null && connectRequest.startsWith("CONNECT ")) {
// Parse the CONNECT request to extract host and port
// Format: CONNECT host:port HTTP/1.x
String[] parts = connectRequest.split("\\s+");
if (parts.length >= 2) {
String hostPort = parts[1];
String[] hostPortParts = hostPort.split(":");
if (hostPortParts.length >= 1) {
receivedHost = hostPortParts[0];
}
}

// Read and discard headers until empty line
String line;
while ((line = readLine(in)) != null && !line.isEmpty()) {
// consume headers
}

// Send 200 OK response
out.write("HTTP/1.1 200 Connection Established\r\n".getBytes());
out.write("Content-Length: 0\r\n".getBytes());
out.write("\r\n".getBytes());
out.flush();

// Keep the connection open for a bit so the client can use it
Thread.sleep(1000);
}
} catch (IOException | InterruptedException e) {
// Connection closed or error
}
}

private String readLine(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
int ch;
boolean gotCR = false;
while ((ch = in.read()) != -1) {
if (ch == '\r') {
gotCR = true;
} else if (ch == '\n' && gotCR) {
return sb.deleteCharAt(sb.length() - 1).toString();
} else {
gotCR = false;
sb.append((char) ch);
}
}
return sb.length() > 0 ? sb.toString() : null;
}

public void shutdown() throws IOException {
serverSocket.close();
}
}

/**
* Test that when a proxy is configured and a domain name (non-IP) is used,
* the SocketDataPort preserves the hostname instead of resolving it to IP.
* This allows proxies with domain whitelisting to work correctly.
*/
@Test
public void testProxyReceivesDomainNameWithEnableInetAddressCreateUnresolved() throws Exception {
testProxyHostnameResolution(
true, // enableNoResolveHostnames
"nats://localhost:4222",
false // expectIpAddress
);
}

/**
* Test that WITHOUT isEnableInetAddressCreateUnresolved(), the proxy receives an IP address instead
* of the domain name. This demonstrates the bug that was fixed.
*
* When isEnableInetAddressCreateUnresolved() is NOT set and a proxy is configured, the hostname
* gets resolved to an IP address before being sent to the proxy. This breaks
* proxies with domain name whitelisting.
*/
@Test
public void testProxyReceivesIpAddressWithoutEnableInetAddressCreateUnresolved() throws Exception {
testProxyHostnameResolution(
false, // disableNoResolveHostnames
"nats://localhost:4222",
true // expectIpAddress
);
}

/**
* Helper method to test proxy hostname resolution behavior.
*
* @param useEnableInetAddressCreateUnresolved Whether to enable isEnableInetAddressCreateUnresolved() option
* @param targetUri The URI to connect to
* @param expectIpAddress True if expecting proxy to receive an IP, false for hostname
*/
private void testProxyHostnameResolution(boolean useEnableInetAddressCreateUnresolved, String targetUri,
boolean expectIpAddress)
throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(3);
WhitelistingProxyServer proxyServer = new WhitelistingProxyServer(executor);

try {
executor.submit(proxyServer);

Options.Builder optionsBuilder = new Options.Builder()
.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", proxyServer.getPort())))
.noReconnect();

if (useEnableInetAddressCreateUnresolved) {
optionsBuilder.enableInetAddressCreateUnresolved();
}

Options options = optionsBuilder.build();
MockNatsConnection mockConnection = new MockNatsConnection(options);
SocketDataPort dataPort = new SocketDataPort();
NatsUri nuri = new NatsUri(targetUri);

try {
dataPort.connect(mockConnection, nuri, 5_000_000_000L);
} catch (Exception e) {
// Expected - connection will fail since there's no real server
}

String receivedHost = proxyServer.getReceivedHost();
if (receivedHost != null) {
if (expectIpAddress) {
assertTrue(
isIpAddress(receivedHost),
"Expected IP address but proxy received: " + receivedHost
);
} else {
assertFalse(
isIpAddress(receivedHost),
"Expected hostname but proxy received IP: " + receivedHost
);
}
}
} finally {
safeShutdown(proxyServer, executor);
}
}

/**
* Safely shutdown the proxy server and executor.
*/
private void safeShutdown(WhitelistingProxyServer proxyServer, ExecutorService executor) {
try {
proxyServer.shutdown();
} catch (IOException e) {
// ignore
}
executor.shutdown();
}

/**
* Check if a string is an IP address (IPv4 or IPv6)
*/
private boolean isIpAddress(String host) {
if (host == null) {
return false;
}
try {
InetAddress.getByName(host);
// If getByName doesn't throw and we only got back an IP, it's likely an IP
// This is a simple heuristic check
return host.matches("^\\d+\\.\\d+\\.\\d+\\.\\d+$") || host.startsWith("[");
} catch (UnknownHostException e) {
return false;
}
}
}
Loading