From b3412c023dc2dd0b7f758378ca31a4b5819b4348 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 16 Jul 2026 07:56:22 -0500 Subject: [PATCH 1/3] [grid] honor client-advertised se:remoteUrl for reachable BiDi/CDP/VNC URLs --- .../webdriver/Remote/HttpCommandExecutor.cs | 5 + dotnet/src/webdriver/WebDriver.cs | 5 + .../selenium/grid/node/local/LocalNode.java | 36 ++++- .../selenium/remote/RemoteWebDriver.java | 11 ++ .../remote/RemoteWebDriverBuilder.java | 8 +- .../openqa/selenium/grid/node/NodeTest.java | 130 ++++++++++++++++++ .../remote/RemoteWebDriverBuilderTest.java | 18 +++ .../remote/RemoteWebDriverUnitTest.java | 14 ++ javascript/selenium-webdriver/index.js | 5 + py/selenium/webdriver/remote/webdriver.py | 10 ++ .../webdriver/remote/new_session_tests.py | 8 +- rb/lib/selenium/webdriver/remote/driver.rb | 1 + .../selenium/webdriver/remote/driver_spec.rb | 6 +- 13 files changed, 245 insertions(+), 12 deletions(-) diff --git a/dotnet/src/webdriver/Remote/HttpCommandExecutor.cs b/dotnet/src/webdriver/Remote/HttpCommandExecutor.cs index 2066ae14a3f3f..c226e8a943d14 100644 --- a/dotnet/src/webdriver/Remote/HttpCommandExecutor.cs +++ b/dotnet/src/webdriver/Remote/HttpCommandExecutor.cs @@ -111,6 +111,11 @@ public HttpCommandExecutor(Uri addressOfRemoteServer, TimeSpan timeout, bool ena /// public string UserAgent { get; set; } + /// + /// Gets the address of the remote end this executor connects to. + /// + internal Uri RemoteServerUri => this.remoteServerUri; + /// /// Gets the repository of objects containing information about commands. /// diff --git a/dotnet/src/webdriver/WebDriver.cs b/dotnet/src/webdriver/WebDriver.cs index 6e01e32d72cc0..90d160268c700 100644 --- a/dotnet/src/webdriver/WebDriver.cs +++ b/dotnet/src/webdriver/WebDriver.cs @@ -604,6 +604,11 @@ protected void StartSession(ICapabilities capabilities) { Dictionary matchCapabilities = this.GetCapabilitiesDictionary(capabilities); + if (this.CommandExecutor is Remote.HttpCommandExecutor httpExecutor) + { + matchCapabilities["se:remoteUrl"] = httpExecutor.RemoteServerUri.AbsoluteUri; + } + List firstMatchCapabilitiesList = new List(); firstMatchCapabilitiesList.Add(matchCapabilities); diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java index 06b78eaa89405..0c4c30429e2c0 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -1221,10 +1221,12 @@ private Session createExternalSession( Capabilities toUse = ImmutableCapabilities.copyOf(requestCapabilities.merge(other.getCapabilities())); + URI baseUri = resolvePublicGridUri(toUse); + // Add se:cdp if necessary to send the cdp url back if ((isSupportingCdp || toUse.getCapability("se:cdp") != null) && cdpEnabled) { String cdpPath = String.format("/session/%s/se/cdp", other.getId()); - toUse = new PersistentCapabilities(toUse).setCapability("se:cdp", rewrite(cdpPath)); + toUse = new PersistentCapabilities(toUse).setCapability("se:cdp", rewrite(cdpPath, baseUri)); } else { // Remove any se:cdp* from the response, CDP is not supported nor enabled MutableCapabilities cdpFiltered = new MutableCapabilities(); @@ -1259,7 +1261,7 @@ private Session createExternalSession( toUse = new PersistentCapabilities(toUse) .setCapability("se:gridWebSocketUrl", uri) - .setCapability("webSocketUrl", rewrite(bidiPath)); + .setCapability("webSocketUrl", rewrite(bidiPath, baseUri)); } else { // Remove any "webSocketUrl" from the response, BiDi is not supported nor enabled MutableCapabilities bidiFiltered = new MutableCapabilities(); @@ -1278,23 +1280,43 @@ private Session createExternalSession( boolean isVncEnabled = toUse.getCapability("se:vncLocalAddress") != null; if (isVncEnabled) { String vncPath = String.format("/session/%s/se/vnc", other.getId()); - toUse = new PersistentCapabilities(toUse).setCapability("se:vnc", rewrite(vncPath)); + toUse = new PersistentCapabilities(toUse).setCapability("se:vnc", rewrite(vncPath, baseUri)); } return new Session(other.getId(), externalUri, other.getStereotype(), toUse, Instant.now()); } - private URI rewrite(String path) { + private URI rewrite(String path, URI baseUri) { try { - String scheme = "https".equals(gridUri.getScheme()) ? "wss" : "ws"; - path = NodeOptions.normalizeSubPath(gridUri.getPath()) + path; + String scheme = "https".equals(baseUri.getScheme()) ? "wss" : "ws"; + path = NodeOptions.normalizeSubPath(baseUri.getPath()) + path; return new URI( - scheme, gridUri.getUserInfo(), gridUri.getHost(), gridUri.getPort(), path, null, null); + scheme, baseUri.getUserInfo(), baseUri.getHost(), baseUri.getPort(), path, null, null); } catch (URISyntaxException e) { throw new RuntimeException(e); } } + // A configured grid-url always wins; only when the node falls back to its auto-detected address + // (which may be unreachable behind Docker/proxy) do we use the client-advertised se:remoteUrl. + private URI resolvePublicGridUri(Capabilities caps) { + if (!gridUri.equals(externalUri)) { + return gridUri; + } + Object raw = caps.getCapability("se:remoteUrl"); + if (raw instanceof String && !((String) raw).isEmpty()) { + try { + URI uri = new URI((String) raw); + if (uri.getHost() != null) { + return uri; + } + } catch (URISyntaxException e) { + // Fall back to gridUri. + } + } + return gridUri; + } + @Override public NodeStatus getStatus() { Set slots = diff --git a/java/src/org/openqa/selenium/remote/RemoteWebDriver.java b/java/src/org/openqa/selenium/remote/RemoteWebDriver.java index fccf4317d5eba..a2261e3daac1e 100644 --- a/java/src/org/openqa/selenium/remote/RemoteWebDriver.java +++ b/java/src/org/openqa/selenium/remote/RemoteWebDriver.java @@ -260,9 +260,20 @@ protected void setSessionId(String opaqueKey) { sessionId = new SessionId(opaqueKey); } + private Capabilities addRemoteUrl(Capabilities capabilities) { + URI baseUri = clientConfig.baseUri(); + if (baseUri == null) { + return capabilities; + } + MutableCapabilities withRemoteUrl = new MutableCapabilities(capabilities); + withRemoteUrl.setCapability("se:remoteUrl", baseUri.toString()); + return withRemoteUrl; + } + protected void startSession(Capabilities capabilities) { checkNonW3CCapabilities(capabilities); checkChromeW3CFalse(capabilities); + capabilities = addRemoteUrl(capabilities); try { Response response = execute(DriverCommand.NEW_SESSION(singleton(capabilities))); diff --git a/java/src/org/openqa/selenium/remote/RemoteWebDriverBuilder.java b/java/src/org/openqa/selenium/remote/RemoteWebDriverBuilder.java index 036d441f1d733..3c120d2725fd6 100644 --- a/java/src/org/openqa/selenium/remote/RemoteWebDriverBuilder.java +++ b/java/src/org/openqa/selenium/remote/RemoteWebDriverBuilder.java @@ -529,8 +529,14 @@ private Set getClobberedCapabilities() { private NewSessionPayload getPayload() { Map roughPayload = new TreeMap<>(metadata); + Map alwaysMatch = new TreeMap<>(additionalCapabilities); + URI baseUri = getBaseUri(); + if (baseUri != null) { + alwaysMatch.put("se:remoteUrl", baseUri.toString()); + } + Map w3cCaps = new TreeMap<>(); - w3cCaps.put("alwaysMatch", additionalCapabilities); + w3cCaps.put("alwaysMatch", alwaysMatch); if (!requestedCapabilities.isEmpty()) { w3cCaps.put("firstMatch", requestedCapabilities); } diff --git a/java/test/org/openqa/selenium/grid/node/NodeTest.java b/java/test/org/openqa/selenium/grid/node/NodeTest.java index 92f55004b4e12..074a106b4a21f 100644 --- a/java/test/org/openqa/selenium/grid/node/NodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/NodeTest.java @@ -209,6 +209,136 @@ void shouldCreateASessionIfTheCorrectCapabilitiesArePassedToIt() { assertThat(sessionResponse.getSession()).isNotNull(); } + @Test + void usesClientReachableAddressForProxiedUrlsWhenRemoteUrlProvided() { + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:9999/session/" + session.getId() + "/se/vnc"); + } + + @Test + void preservesCredentialsFromClientAdvertisedRemoteUrl() { + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://user:secret@localhost:9999"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://user:secret@localhost:9999/session/" + session.getId() + "/se/vnc"); + } + + @Test + void ignoresRemoteUrlWhenPublicGridUrlIsConfigured() throws URISyntaxException { + URI configuredGridUri = new URI("http://grid.example:4444"); + + class Handler extends Session implements HttpHandler { + private Handler(Capabilities capabilities) { + super(new SessionId(UUID.randomUUID()), uri, stereotype, capabilities, Instant.now()); + } + + @Override + public HttpResponse execute(HttpRequest req) throws UncheckedIOException { + return new HttpResponse(); + } + } + + LocalNode node = + LocalNode.builder(tracer, bus, uri, configuredGridUri, registrationSecret) + .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) + .build(); + + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999"); + + Either response = + node.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://grid.example:4444/session/" + session.getId() + "/se/vnc"); + } + + @Test + void preservesCredentialsFromConfiguredPublicGridUrl() throws URISyntaxException { + URI configuredGridUri = new URI("http://user:secret@grid.example:4444"); + + class Handler extends Session implements HttpHandler { + private Handler(Capabilities capabilities) { + super(new SessionId(UUID.randomUUID()), uri, stereotype, capabilities, Instant.now()); + } + + @Override + public HttpResponse execute(HttpRequest req) throws UncheckedIOException { + return new HttpResponse(); + } + } + + LocalNode node = + LocalNode.builder(tracer, bus, uri, configuredGridUri, registrationSecret) + .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) + .build(); + + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999"); + + Either response = + node.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://user:secret@grid.example:4444/session/" + session.getId() + "/se/vnc"); + } + + @Test + void fallsBackToGridAddressForProxiedUrlsWithoutRemoteUrl() { + Capabilities request = + new ImmutableCapabilities("browserName", "cheese", "se:vncLocalAddress", "localhost:5900"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:1234/session/" + session.getId() + "/se/vnc"); + } + @Test void shouldRetryIfNoMatchingSlotIsAvailable() { Node local = diff --git a/java/test/org/openqa/selenium/remote/RemoteWebDriverBuilderTest.java b/java/test/org/openqa/selenium/remote/RemoteWebDriverBuilderTest.java index 4e9b885ccb008..de9b462fe7598 100644 --- a/java/test/org/openqa/selenium/remote/RemoteWebDriverBuilderTest.java +++ b/java/test/org/openqa/selenium/remote/RemoteWebDriverBuilderTest.java @@ -179,6 +179,24 @@ void shouldAllowMetaDataToBeSet() { assertThat(seen).isTrue(); } + @Test + void advertisesRemoteUrlToTheServer() { + AtomicReference seen = new AtomicReference<>(); + + RemoteWebDriver.builder() + .oneOf(new FirefoxOptions()) + .address("http://localhost:34576") + .connectingWith( + config -> + req -> { + seen.set(listCapabilities(req).get(0).getCapability("se:remoteUrl")); + return CANNED_SESSION_RESPONSE; + }) + .build(); + + assertThat(seen.get()).isEqualTo("http://localhost:34576"); + } + @Test void doesNotAllowFirstMatchToBeUsedAsAMetadataNameAsItIsConfusing() { RemoteWebDriverBuilder builder = RemoteWebDriver.builder(); diff --git a/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java b/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java index df7aa53bbfb84..131e309d0f54d 100644 --- a/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java +++ b/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java @@ -36,6 +36,7 @@ import static org.openqa.selenium.remote.WebDriverFixture.webDriverExceptionResponder; import java.io.IOException; +import java.net.URI; import java.net.URL; import java.time.Duration; import java.util.ArrayList; @@ -63,6 +64,7 @@ import org.openqa.selenium.WebElement; import org.openqa.selenium.WindowType; import org.openqa.selenium.internal.Debug; +import org.openqa.selenium.remote.http.ClientConfig; import org.openqa.selenium.virtualauthenticator.VirtualAuthenticator; import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; @@ -71,6 +73,18 @@ class RemoteWebDriverUnitTest { private static final String ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"; + @Test + void advertisesRemoteUrlWhenStartingRemoteSession() { + RemoteWebDriver driver = + new RemoteWebDriver( + WebDriverFixture.prepareExecutorMock(echoCapabilities), + new ImmutableCapabilities("browserName", "chrome"), + ClientConfig.defaultConfig().baseUri(URI.create("http://grid.example:4444/wd/hub"))); + + assertThat(driver.getCapabilities().getCapability("se:remoteUrl")) + .isEqualTo("http://grid.example:4444/wd/hub"); + } + @Test void canHandleGetCommand() { WebDriverFixture fixture = new WebDriverFixture(echoCapabilities, nullValueResponder); diff --git a/javascript/selenium-webdriver/index.js b/javascript/selenium-webdriver/index.js index 32e7486dbaa9d..244fa82e6f7ee 100644 --- a/javascript/selenium-webdriver/index.js +++ b/javascript/selenium-webdriver/index.js @@ -650,6 +650,11 @@ class Builder { if (url) { this.log_.fine('Creating session on remote server') + + if (typeof url === 'string') { + capabilities.set('se:remoteUrl', url) + } + let client = Promise.resolve(url).then((url) => new _http.HttpClient(url, this.agent_, this.proxy_)) let executor = new _http.Executor(client) diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index f3e95859276cc..266c1cdd0838e 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -391,6 +391,9 @@ def start_session(self, capabilities: dict) -> None: Args: capabilities: A capabilities dict to start the session with. """ + remote_url = self._remote_url() + if remote_url: + capabilities = {**capabilities, "se:remoteUrl": remote_url} caps = _create_caps(capabilities) try: response = self.execute(Command.NEW_SESSION, caps)["value"] @@ -401,6 +404,13 @@ def start_session(self, capabilities: dict) -> None: self.service.stop() raise + def _remote_url(self) -> str | None: + """The address used to reach the Grid, advertised as ``se:remoteUrl`` (None for local drivers).""" + if getattr(self, "service", None) is not None: + return None + client_config = getattr(self.command_executor, "client_config", None) + return getattr(client_config, "remote_server_addr", None) or None + def _wrap_value(self, value): if isinstance(value, dict): converted = {} diff --git a/py/test/unit/selenium/webdriver/remote/new_session_tests.py b/py/test/unit/selenium/webdriver/remote/new_session_tests.py index e593bd9dda929..6e43e094b8e1d 100644 --- a/py/test/unit/selenium/webdriver/remote/new_session_tests.py +++ b/py/test/unit/selenium/webdriver/remote/new_session_tests.py @@ -34,8 +34,12 @@ def test_converts_proxy_type_value_to_lowercase_for_w3c(mocker): proxy = Proxy({"proxyType": ProxyType.MANUAL, "httpProxy": "foo"}) options.proxy = proxy WebDriver(options=options) - expected_params = {"capabilities": {"firstMatch": [{}], "alwaysMatch": w3c_caps}} - mock.assert_called_with(Command.NEW_SESSION, expected_params) + command, params = mock.call_args[0] + assert command == Command.NEW_SESSION + always_match = params["capabilities"]["alwaysMatch"] + always_match.pop("se:remoteUrl", None) + assert params["capabilities"]["firstMatch"] == [{}] + assert always_match == w3c_caps def test_works_as_context_manager(mocker): diff --git a/rb/lib/selenium/webdriver/remote/driver.rb b/rb/lib/selenium/webdriver/remote/driver.rb index 5e530d624433d..b501144b9495d 100644 --- a/rb/lib/selenium/webdriver/remote/driver.rb +++ b/rb/lib/selenium/webdriver/remote/driver.rb @@ -38,6 +38,7 @@ def initialize(capabilities: nil, options: nil, service: nil, url: nil, http_cli caps = process_options(options, capabilities) http_client ||= Remote::Http::Default.new(client_config: client_config) http_client.server_url = url || client_config&.server_url || "http://#{Platform.localhost}:4444/wd/hub" + caps['se:remoteUrl'] = http_client.client_config.server_url.to_s super(caps: caps, http_client: http_client, **) @bridge.file_detector = ->((filename, *)) { File.exist?(filename) && filename.to_s } command_list = @bridge.command_list diff --git a/rb/spec/unit/selenium/webdriver/remote/driver_spec.rb b/rb/spec/unit/selenium/webdriver/remote/driver_spec.rb index 51d3c17fa357f..49920495241c9 100644 --- a/rb/spec/unit/selenium/webdriver/remote/driver_spec.rb +++ b/rb/spec/unit/selenium/webdriver/remote/driver_spec.rb @@ -30,9 +30,11 @@ module Remote end def expect_request(body: nil, endpoint: nil) - body = (body || {capabilities: {alwaysMatch: {browserName: 'chrome', 'goog:chromeOptions': {}}}}).to_json endpoint ||= 'http://127.0.0.1:4444/wd/hub/session' - stub_request(:post, endpoint).with(body: body).to_return(valid_response) + body ||= {capabilities: {alwaysMatch: {browserName: 'chrome', 'goog:chromeOptions': {}}}} + always_match = body.dig(:capabilities, :alwaysMatch) + always_match['se:remoteUrl'] = endpoint.delete_suffix('session') if always_match + stub_request(:post, endpoint).with(body: body.to_json).to_return(valid_response) end it 'requires parameters' do From 9a20e5df3bf1c5b5b4fadb604e78f67f43fb776a Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 16 Jul 2026 19:10:50 -0500 Subject: [PATCH 2/3] [grid] resolve grid-url precedence via explicit config flag instead of address comparison --- .../selenium/grid/node/local/LocalNode.java | 12 +++++- .../grid/node/local/LocalNodeFactory.java | 1 + .../openqa/selenium/grid/node/NodeTest.java | 41 +++++++++++++++++++ .../remote/RemoteWebDriverUnitTest.java | 12 ++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java index 0c4c30429e2c0..e0a4067a310d5 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -145,6 +145,7 @@ public class LocalNode extends Node implements Closeable { private final EventBus bus; private final URI externalUri; private final URI gridUri; + private final boolean gridUrlSpecified; private final Duration heartbeatPeriod; private final HealthCheck healthCheck; private final int maxSessionCount; @@ -175,6 +176,7 @@ protected LocalNode( EventBus bus, URI uri, URI gridUri, + boolean gridUrlSpecified, @Nullable HealthCheck healthCheck, int maxSessionCount, int drainAfterSessionCount, @@ -200,6 +202,7 @@ protected LocalNode( this.externalUri = Require.nonNull("Remote node URI", uri); this.gridUri = Require.nonNull("Grid URI", gridUri); + this.gridUrlSpecified = gridUrlSpecified; this.maxSessionCount = Math.min(Require.positive("Max session count", maxSessionCount), factories.size()); this.heartbeatPeriod = heartbeatPeriod; @@ -1300,7 +1303,7 @@ private URI rewrite(String path, URI baseUri) { // A configured grid-url always wins; only when the node falls back to its auto-detected address // (which may be unreachable behind Docker/proxy) do we use the client-advertised se:remoteUrl. private URI resolvePublicGridUri(Capabilities caps) { - if (!gridUri.equals(externalUri)) { + if (gridUrlSpecified) { return gridUri; } Object raw = caps.getCapability("se:remoteUrl"); @@ -1479,6 +1482,7 @@ public static class Builder { private final Secret registrationSecret; private final List factories; private final List interceptors = new ArrayList<>(); + private boolean gridUrlSpecified = false; private int maxSessions = NodeOptions.DEFAULT_MAX_SESSIONS; private int drainAfterSessionCount = NodeOptions.DEFAULT_DRAIN_AFTER_SESSION_COUNT; private boolean cdpEnabled = NodeOptions.DEFAULT_ENABLE_CDP; @@ -1570,12 +1574,18 @@ public Builder addInterceptor(NodeCommandInterceptor interceptor) { return this; } + public Builder gridUrlSpecified(boolean configured) { + this.gridUrlSpecified = configured; + return this; + } + public LocalNode build() { return new LocalNode( tracer, bus, uri, gridUri, + gridUrlSpecified, healthCheck, maxSessions, drainAfterSessionCount, diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java b/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java index 25c7f816159a8..631b77680d717 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java @@ -70,6 +70,7 @@ public static Node create(Config config) { serverOptions.getExternalUri(), nodeOptions.getPublicGridUri().orElseGet(serverOptions::getExternalUri), secretOptions.getRegistrationSecret()) + .gridUrlSpecified(nodeOptions.getPublicGridUri().isPresent()) .maximumConcurrentSessions(nodeOptions.getMaxSessions()) .sessionTimeout(sessionTimeout) .drainAfterSessionCount(nodeOptions.getDrainAfterSessionCount()) diff --git a/java/test/org/openqa/selenium/grid/node/NodeTest.java b/java/test/org/openqa/selenium/grid/node/NodeTest.java index 074a106b4a21f..af6479ad6dfa8 100644 --- a/java/test/org/openqa/selenium/grid/node/NodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/NodeTest.java @@ -266,6 +266,7 @@ public HttpResponse execute(HttpRequest req) throws UncheckedIOException { LocalNode node = LocalNode.builder(tracer, bus, uri, configuredGridUri, registrationSecret) + .gridUrlSpecified(true) .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) .build(); @@ -304,6 +305,7 @@ public HttpResponse execute(HttpRequest req) throws UncheckedIOException { LocalNode node = LocalNode.builder(tracer, bus, uri, configuredGridUri, registrationSecret) + .gridUrlSpecified(true) .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) .build(); @@ -325,6 +327,45 @@ public HttpResponse execute(HttpRequest req) throws UncheckedIOException { .isEqualTo("ws://user:secret@grid.example:4444/session/" + session.getId() + "/se/vnc"); } + @Test + void ignoresRemoteUrlWhenGridUrlIsConfiguredEvenIfItEqualsNodeAddress() { + // Edge case: grid-url is explicitly configured to the same value as the node's own externalUri. + // The explicit configuration must still win over a client-advertised se:remoteUrl. + class Handler extends Session implements HttpHandler { + private Handler(Capabilities capabilities) { + super(new SessionId(UUID.randomUUID()), uri, stereotype, capabilities, Instant.now()); + } + + @Override + public HttpResponse execute(HttpRequest req) throws UncheckedIOException { + return new HttpResponse(); + } + } + + LocalNode node = + LocalNode.builder(tracer, bus, uri, uri, registrationSecret) + .gridUrlSpecified(true) + .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) + .build(); + + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "http://localhost:9999"); + + Either response = + node.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:1234/session/" + session.getId() + "/se/vnc"); + } + @Test void fallsBackToGridAddressForProxiedUrlsWithoutRemoteUrl() { Capabilities request = diff --git a/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java b/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java index 131e309d0f54d..fe1d248af4b46 100644 --- a/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java +++ b/java/test/org/openqa/selenium/remote/RemoteWebDriverUnitTest.java @@ -85,6 +85,18 @@ void advertisesRemoteUrlWhenStartingRemoteSession() { .isEqualTo("http://grid.example:4444/wd/hub"); } + @Test + void doesNotAdvertiseRemoteUrlForLocalSession() { + // Local drivers (e.g. ChromeDriver) construct with ClientConfig.defaultConfig(), whose baseUri + // is null, so no se:remoteUrl is added even though the executor targets a driver-service URL. + RemoteWebDriver driver = + new RemoteWebDriver( + WebDriverFixture.prepareExecutorMock(echoCapabilities), + new ImmutableCapabilities("browserName", "chrome")); + + assertThat(driver.getCapabilities().getCapability("se:remoteUrl")).isNull(); + } + @Test void canHandleGetCommand() { WebDriverFixture fixture = new WebDriverFixture(echoCapabilities, nullValueResponder); From c8274b10db3883affdc4a70d0726192398b7c819 Mon Sep 17 00:00:00 2001 From: Titus Fortner Date: Thu, 16 Jul 2026 20:04:23 -0500 Subject: [PATCH 3/3] [grid] only honor se:remoteUrl when it is an http(s) URL --- .../selenium/grid/node/local/LocalNode.java | 4 +++- .../openqa/selenium/grid/node/NodeTest.java | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java index e0a4067a310d5..22c33626f22a6 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -1310,7 +1310,9 @@ private URI resolvePublicGridUri(Capabilities caps) { if (raw instanceof String && !((String) raw).isEmpty()) { try { URI uri = new URI((String) raw); - if (uri.getHost() != null) { + String scheme = uri.getScheme(); + if (uri.getHost() != null + && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { return uri; } } catch (URISyntaxException e) { diff --git a/java/test/org/openqa/selenium/grid/node/NodeTest.java b/java/test/org/openqa/selenium/grid/node/NodeTest.java index af6479ad6dfa8..ac7a9aa46cba6 100644 --- a/java/test/org/openqa/selenium/grid/node/NodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/NodeTest.java @@ -366,6 +366,26 @@ public HttpResponse execute(HttpRequest req) throws UncheckedIOException { .isEqualTo("ws://localhost:1234/session/" + session.getId() + "/se/vnc"); } + @Test + void ignoresRemoteUrlWithoutHttpScheme() { + Capabilities request = + new ImmutableCapabilities( + "browserName", + "cheese", + "se:vncLocalAddress", + "localhost:5900", + "se:remoteUrl", + "ftp://evil:21"); + + Either response = + local.newSession(createSessionRequest(request)); + assertThatEither(response).isRight(); + + Session session = response.right().getSession(); + assertThat(String.valueOf(session.getCapabilities().getCapability("se:vnc"))) + .isEqualTo("ws://localhost:1234/session/" + session.getId() + "/se/vnc"); + } + @Test void fallsBackToGridAddressForProxiedUrlsWithoutRemoteUrl() { Capabilities request =