Jenkins has a DNS rebinding vulnerability in WebSocket CLI origin validation
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
2.442 → fixed in 2.555
Details
Jenkins 2.442 through 2.554 (both inclusive), LTS 2.426.3 through LTS 2.541.2 (both inclusive) performs origin validation of requests made through the CLI WebSocket endpoint by computing the expected origin for comparison using the Host or X-Forwarded-Host HTTP request headers, making it vulnerable to DNS rebinding attacks that allow bypassing origin validation.
The fix
[SECURITY-3674]
core/src/main/java/hudson/cli/CLIAction.java+16 −2
@@ -48,6 +48,7 @@import java.util.logging.Level;import java.util.logging.Logger;import jenkins.model.Jenkins;+import jenkins.model.JenkinsLocationConfiguration;import jenkins.util.FullDuplexHttpService;import jenkins.util.SystemProperties;import jenkins.websocket.WebSocketSession;@@ -80,6 +81,12 @@ public class CLIAction implements UnprotectedRootAction, StaplerProxy {*//* package-private for testing */ static /* non-final for Script Console */ Boolean ALLOW_WEBSOCKET = SystemProperties.optBoolean(CLIAction.class.getName() + ".ALLOW_WEBSOCKET");+/**+* If this is set to {@code true}, {@link Jenkins#getRootUrlFromRequest()} is used to validate the {@code Origin} header.+* This can be a security issue if Jenkins is running on a local network without authentication as it allows DNS rebinding attacks.+*/+/* package-private for testing */ static /* non-final for Script Console */ boolean ACCEPT_URL_FROM_REQUEST = SystemProperties.getBoolean(CLIAction.class.getName() + ".ACCEPT_URL_FROM_REQUEST");+private final transient Map<UUID, FullDuplexHttpService> duplexServices = new ConcurrentHashMap<>();@Override@@ -142,7 +149,14 @@ public HttpResponse doWs(StaplerRequest2 req) {if (ALLOW_WEBSOCKET == null) {final String actualOrigin = req.getHeader("Origin");-String o = Jenkins.get().getRootUrlFromRequest();+// If Jenkins URL is not configured, Jenkins#getRootUrl falls back to Jenkins#getRootUrlFromRequest, so look this up directly+String o = JenkinsLocationConfiguration.get().getUrl();+if (ACCEPT_URL_FROM_REQUEST) {+o = Jenkins.get().getRootUrlFromRequest();+}+if (o == null) {+return statusWithExplanation(HttpServletResponse.SC_FORBIDDEN, "Jenkins URL is not configured (set Jenkins URL in the configuration)");+}String removeSuffix1 = "/";if (o.endsWith(removeSuffix1)) {o = o.substring(0, o.length() - removeSuffix1.length());@@ -154,7 +168,7 @@ public HttpResponse doWs(StaplerRequest2 req) {final String expectedOrigin = o;if (actualOrigin == null || !actualOrigin.equals(expectedOrigin)) {-LOGGER.log(Level.FINE, () -> "Rejecting origin: " + actualOrigin + "; expected was from request: " + expectedOrigin);+LOGGER.log(Level.FINE, () -> "Rejecting origin: " + actualOrigin + "; expected was: " + expectedOrigin);return statusWithExplanation(HttpServletResponse.SC_FORBIDDEN, "Unexpected request origin (check your reverse proxy settings)");}} else if (!ALLOW_WEBSOCKET) {
test/src/test/java/hudson/cli/Security3315Test.java+67 −0
@@ -7,12 +7,15 @@import java.net.URL;import java.util.Arrays;import java.util.List;+import jenkins.model.JenkinsLocationConfiguration;import org.htmlunit.HttpMethod;import org.htmlunit.Page;import org.htmlunit.WebRequest;import org.junit.jupiter.api.BeforeEach;+import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.MethodSource;+import org.jvnet.hudson.test.Issue;import org.jvnet.hudson.test.JenkinsRule;import org.jvnet.hudson.test.junit.jupiter.WithJenkins;@@ -53,6 +56,70 @@ void test(Boolean allowWs) throws IOException {request.setAdditionalHeader("Origin", jenkinsUrl.getProtocol() + "://" + jenkinsUrl.getHost() + ":" + jenkinsUrl.getPort());page = wc.getPage(request);assertThat(page.getWebResponse().getStatusCode(), is(allowWs == Boolean.FALSE ? 403 : 400)); // Reject correct Origin if ALLOW_WS is explicitly false+} finally {+CLIAction.ALLOW_WEBSOCKET = null;+}+}++@Issue("SECURITY-3674")+@Test+void testDifferentDomain() throws IOException {+final URL jenkinsUrl = j.getURL();+// safety assertions+final String jenkinsUrlHost = "localhost";+assertThat(jenkinsUrl.getHost(), is(jenkinsUrlHost));+assertThat(JenkinsLocationConfiguration.get().getUrl(), is(jenkinsUrl.toString()));++try (JenkinsRule.WebClient wc = j.createWebClient().withThrowExceptionOnFailingStatusCode(false)) {+{+// control: Regular request to the configured domain+final WebRequest request = new WebRequest(new URL(jenkinsUrl.toString() + "cli/ws"), HttpMethod.GET);+// correct Origin+request.setAdditionalHeader("Origin", jenkinsUrl.getProtocol() + "://" + jenkinsUrl.getHost() + ":" + jenkinsUrl.getPort());+final Page page = wc.getPage(request);+assertThat(page.getWebResponse().getStatusCode(), is(400)); // 400 is WebSocket "success" (HTMLUnit doesn't support it)+}++final String alternativeUrlHost = "127.0.0.1";+final URL alternativeUrl = new URL(jenkinsUrl.toString().replace(jenkinsUrlHost, alternativeUrlHost));+{+// wrong but matching request domain and origin, prohibited by default+final WebRequest request = new WebRequest(new URL(alternativeUrl.toString() + "cli/ws"), HttpMethod.GET);+request.setAdditionalHeader("Origin", jenkinsUrl.getProtocol() + "://" + alternativeUrlHost + ":" + jenkinsUrl.getPort());+final Page page = wc.getPage(request);+assertThat(page.getWebResponse().getStatusCode(), is(403));+}++CLIAction.ACCEPT_URL_FROM_REQUEST = true;+try {+// wrong but matching request domain and origin, allowed by escape hatch+final WebRequest request = new WebRequest(new URL(alternativeUrl.toString() + "cli/ws"), HttpMethod.GET);+request.setAdditionalHeader("Origin", jenkinsUrl.getProtocol() + "://" + alternativeUrlHost + ":" + jenkinsUrl.getPort());+final Page page = wc.getPage(request);+assertThat(page.getWebResponse().getStatusCode(), is(400));+} finally {+CLIAction.ACCEPT_URL_FROM_REQUEST = false;+}++JenkinsLocationConfiguration.get().setUrl(null);+{+// correct request domain and origin, but no configured URL, prohibited+final WebRequest request = new WebRequest(new URL(jenkinsUrl.toString() + "cli/ws"), HttpMethod.GET);+request.setAdditionalHeader("Origin", jenkinsUrl.getProtocol() + "://" + jenkinsUrl.getHost() + ":" + jenkinsUrl.getPort());+final Page page = wc.getPage(request);+assertThat(page.getWebResponse().getStatusCode(), is(403));+}++CLIAction.ACCEPT_URL_FROM_REQUEST = true;+try {+// wrong but matching request domain and origin, allowed by escape hatch even without URL+final WebRequest request = new WebRequest(new URL(alternativeUrl.toString() + "cli/ws"), HttpMethod.GET);+request.setAdditionalHeader("Origin", jenkinsUrl.getProtocol() + "://" + alternativeUrlHost + ":" + jenkinsUrl.getPort());+final Page page = wc.getPage(request);+assertThat(page.getWebResponse().getStatusCode(), is(400));+} finally {+CLIAction.ACCEPT_URL_FROM_REQUEST = false;+}}}}