tokenAuth(final String authToken) {
buildTokenAuth(okhttp, baseUrl, authToken);
return this;
@@ -474,6 +490,14 @@ private static Builder> configAlternateSSLHostname(
return builder;
}
+ private static void configAllowCrossOriginRedirect(
+ final Builder> builder,
+ final boolean allow
+ )
+ {
+ builder.okhttp.addNetworkInterceptor(new CrossOriginRedirectInterceptor(allow));
+ }
+
private static void configBypassUrl(
final Builder> builder,
final String bypassUrl
diff --git a/rd-api-client/src/main/java/org/rundeck/client/util/CrossOriginRedirectInterceptor.java b/rd-api-client/src/main/java/org/rundeck/client/util/CrossOriginRedirectInterceptor.java
new file mode 100644
index 00000000..ec98d339
--- /dev/null
+++ b/rd-api-client/src/main/java/org/rundeck/client/util/CrossOriginRedirectInterceptor.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 Rundeck, Inc. (http://rundeck.com)
+ *
+ * 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 org.rundeck.client.util;
+
+import okhttp3.HttpUrl;
+import okhttp3.Interceptor;
+import okhttp3.Response;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Network interceptor that blocks cross-origin HTTP redirects to prevent credential exfiltration.
+ *
+ * When the server responds with a 3xx redirect whose {@code Location} header resolves to a
+ * different scheme, host, or port than the original request, this interceptor throws an
+ * {@link IOException} to abort the request rather than following the redirect with authentication
+ * credentials attached.
+ *
+ * Cross-origin redirect following can be re-enabled by setting
+ * {@code RD_ALLOW_CROSS_ORIGIN_REDIRECT=true} or passing {@code --allow-cross-origin-redirect}
+ * on the command line.
+ */
+public class CrossOriginRedirectInterceptor implements Interceptor {
+
+ private static final Set REDIRECT_CODES = Collections.unmodifiableSet(
+ new HashSet<>(Arrays.asList(301, 302, 303, 307, 308))
+ );
+
+ private final boolean allowCrossOriginRedirect;
+
+ /**
+ * @param allowCrossOriginRedirect when {@code true}, cross-origin redirects are followed
+ * with credentials (original behaviour); when {@code false}
+ * (default), they are blocked with an {@link IOException}
+ */
+ public CrossOriginRedirectInterceptor(final boolean allowCrossOriginRedirect) {
+ this.allowCrossOriginRedirect = allowCrossOriginRedirect;
+ }
+
+ @Override
+ public Response intercept(final Chain chain) throws IOException {
+ Response response = chain.proceed(chain.request());
+ if (!allowCrossOriginRedirect && REDIRECT_CODES.contains(response.code())) {
+ String location = response.header("Location");
+ if (location != null) {
+ HttpUrl requestUrl = chain.request().url();
+ HttpUrl redirectUrl = requestUrl.resolve(location);
+ if (redirectUrl != null && !isSameOrigin(requestUrl, redirectUrl)) {
+ response.close();
+ throw new IOException(
+ "Cross-origin redirect blocked for security: " + redirectUrl
+ + ". To allow cross-origin redirects set RD_ALLOW_CROSS_ORIGIN_REDIRECT=true"
+ + " or pass --allow-cross-origin-redirect."
+ );
+ }
+ }
+ }
+ return response;
+ }
+
+ /**
+ * Returns {@code true} if both URLs share the same scheme, host, and port.
+ */
+ static boolean isSameOrigin(final HttpUrl url1, final HttpUrl url2) {
+ return url1.scheme().equals(url2.scheme())
+ && url1.host().equals(url2.host())
+ && url1.port() == url2.port();
+ }
+}
diff --git a/rd-cli-tool/src/main/java/org/rundeck/client/tool/Main.java b/rd-cli-tool/src/main/java/org/rundeck/client/tool/Main.java
index f008f4a8..1926cc9c 100644
--- a/rd-cli-tool/src/main/java/org/rundeck/client/tool/Main.java
+++ b/rd-cli-tool/src/main/java/org/rundeck/client/tool/Main.java
@@ -91,11 +91,24 @@ public class Main {
@CommandLine.Spec
CommandLine.Model.CommandSpec spec;
+
+ @CommandLine.Option(
+ names = "--allow-cross-origin-redirect",
+ description = "Allow cross-origin redirects to be followed with credentials (security risk)"
+ )
+ boolean allowCrossOriginRedirect;
public static final String
USER_AGENT =
RundeckClient.Builder.getUserAgent("rd-cli-tool/" + org.rundeck.client.Version.VERSION);
public static void main(String[] args) {
+ // Pre-process flags that affect client configuration before Rd is built,
+ // because createRd() reads ConfigSource (env + system props) immediately.
+ // The --allow-cross-origin-redirect flag is converted to a system property
+ // so SysProps can pick it up as RD_ALLOW_CROSS_ORIGIN_REDIRECT.
+ if (Arrays.asList(args).contains("--allow-cross-origin-redirect")) {
+ System.setProperty("rd.allow.cross.origin.redirect", "true");
+ }
int result = -1;
try (Rd rd = createRd()) {
RdToolImpl rd1 = new RdToolImpl(rd);
@@ -349,11 +362,16 @@ public static void setup(final Rd rd, RdBuilder builder) {
boolean insecureSsl = rd.getBool(ENV_INSECURE_SSL, false);
boolean insecureSslNoWarn = rd.getBool(ENV_INSECURE_SSL_NO_WARN, false);
+ boolean allowCrossOriginRedirect = rd.getBool(ENV_ALLOW_CROSS_ORIGIN_REDIRECT, false);
rd.setOutput(builder.finalOutput());
if (insecureSsl && !insecureSslNoWarn) {
rd.getOutput().warning(
"# WARNING: RD_INSECURE_SSL=true, no hostname or certificate trust verification will be performed");
}
+ if (allowCrossOriginRedirect) {
+ rd.getOutput().warning(
+ "# WARNING: RD_ALLOW_CROSS_ORIGIN_REDIRECT=true, cross-origin redirects will be followed with credentials");
+ }
}
static class Rd extends ConfigBase implements RdApp, RdClientConfig, Closeable {
diff --git a/rd-cli-tool/src/test/groovy/org/rundeck/client/tool/MainSpec.groovy b/rd-cli-tool/src/test/groovy/org/rundeck/client/tool/MainSpec.groovy
new file mode 100644
index 00000000..c6b05b54
--- /dev/null
+++ b/rd-cli-tool/src/test/groovy/org/rundeck/client/tool/MainSpec.groovy
@@ -0,0 +1,71 @@
+package org.rundeck.client.tool
+
+import picocli.CommandLine
+import spock.lang.Specification
+
+/**
+ * Tests for the --allow-cross-origin-redirect CLI flag.
+ */
+class MainSpec extends Specification {
+
+ def "picocli accepts --allow-cross-origin-redirect without Unknown option error"() {
+ given:
+ def main = new Main()
+ def commandLine = new CommandLine(main)
+
+ when:
+ commandLine.parseArgs('--allow-cross-origin-redirect', 'system', 'info')
+
+ then:
+ noExceptionThrown()
+ main.allowCrossOriginRedirect == true
+ }
+
+ def "picocli leaves allowCrossOriginRedirect false when flag is absent"() {
+ given:
+ def main = new Main()
+ def commandLine = new CommandLine(main)
+
+ when:
+ commandLine.parseArgs('system', 'info')
+
+ then:
+ noExceptionThrown()
+ main.allowCrossOriginRedirect == false
+ }
+
+ def "pre-processing sets system property when --allow-cross-origin-redirect is in args"() {
+ given:
+ System.clearProperty('rd.allow.cross.origin.redirect')
+ def args = ['--allow-cross-origin-redirect', 'system', 'info'] as String[]
+
+ when:
+ // Mirrors the pre-processing logic in Main.main() that must run before picocli parses
+ if (Arrays.asList(args).contains('--allow-cross-origin-redirect')) {
+ System.setProperty('rd.allow.cross.origin.redirect', 'true')
+ }
+
+ then:
+ System.getProperty('rd.allow.cross.origin.redirect') == 'true'
+
+ cleanup:
+ System.clearProperty('rd.allow.cross.origin.redirect')
+ }
+
+ def "pre-processing does not set system property when flag is absent"() {
+ given:
+ System.clearProperty('rd.allow.cross.origin.redirect')
+ def args = ['system', 'info'] as String[]
+
+ when:
+ if (Arrays.asList(args).contains('--allow-cross-origin-redirect')) {
+ System.setProperty('rd.allow.cross.origin.redirect', 'true')
+ }
+
+ then:
+ System.getProperty('rd.allow.cross.origin.redirect') == null
+
+ cleanup:
+ System.clearProperty('rd.allow.cross.origin.redirect')
+ }
+}
diff --git a/rd-cli-tool/src/test/groovy/org/rundeck/client/util/CrossOriginRedirectInterceptorSpec.groovy b/rd-cli-tool/src/test/groovy/org/rundeck/client/util/CrossOriginRedirectInterceptorSpec.groovy
new file mode 100644
index 00000000..e1c9bf70
--- /dev/null
+++ b/rd-cli-tool/src/test/groovy/org/rundeck/client/util/CrossOriginRedirectInterceptorSpec.groovy
@@ -0,0 +1,216 @@
+package org.rundeck.client.util
+
+import okhttp3.*
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Unit tests for {@link CrossOriginRedirectInterceptor}.
+ */
+class CrossOriginRedirectInterceptorSpec extends Specification {
+
+ private static Request buildRequest(String url) {
+ new Request.Builder().url(url).build()
+ }
+
+ private static Response buildResponse(Request request, int code, String location = null) {
+ def builder = new Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(code)
+ .message('test')
+ .body(ResponseBody.create(MediaType.parse('text/plain'), ''))
+ if (location != null) {
+ builder.header('Location', location)
+ }
+ builder.build()
+ }
+
+ def "cross-origin redirect to different host is blocked by default"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 302, 'http://attacker.example.com/capture')
+
+ when:
+ interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ def e = thrown(IOException)
+ e.message.contains('Cross-origin redirect blocked')
+ e.message.contains('attacker.example.com')
+ }
+
+ def "cross-origin redirect to different scheme is blocked by default"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 301, 'https://myserver.example.com/api/48/system/info')
+
+ when:
+ interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ thrown(IOException)
+ }
+
+ def "cross-origin redirect to different port is blocked by default"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com:4440/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 307, 'http://myserver.example.com:19081/capture')
+
+ when:
+ interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ thrown(IOException)
+ }
+
+ def "same-origin redirect is passed through without error"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 302, 'http://myserver.example.com/api/48/login')
+
+ when:
+ def result = interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ result == response
+ noExceptionThrown()
+ }
+
+ def "cross-origin redirect is allowed with opt-in enabled"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(true)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 302, 'http://attacker.example.com/capture')
+
+ when:
+ def result = interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ result == response
+ noExceptionThrown()
+ }
+
+ def "2xx response is passed through unchanged"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 200)
+
+ when:
+ def result = interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ result == response
+ noExceptionThrown()
+ }
+
+ def "4xx response is passed through unchanged"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 404)
+
+ when:
+ def result = interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ result == response
+ noExceptionThrown()
+ }
+
+ def "5xx response is passed through unchanged"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 500)
+
+ when:
+ def result = interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ result == response
+ noExceptionThrown()
+ }
+
+ def "error message includes the blocked redirect target URL"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def blockedUrl = 'http://evil.com/steal-creds'
+ def response = buildResponse(request, 302, blockedUrl)
+
+ when:
+ interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ def e = thrown(IOException)
+ e.message.contains(blockedUrl)
+ }
+
+ def "error message hints at RD_ALLOW_CROSS_ORIGIN_REDIRECT opt-in"() {
+ given:
+ def interceptor = new CrossOriginRedirectInterceptor(false)
+ def request = buildRequest('http://myserver.example.com/api/48/system/info')
+ def chain = Mock(Interceptor.Chain)
+ def response = buildResponse(request, 302, 'http://other.com/path')
+
+ when:
+ interceptor.intercept(chain)
+
+ then:
+ _ * chain.request() >> request
+ 1 * chain.proceed(request) >> response
+ def e = thrown(IOException)
+ e.message.contains('RD_ALLOW_CROSS_ORIGIN_REDIRECT')
+ }
+
+ @Unroll
+ def "isSameOrigin: #url1 vs #url2 => #expected"() {
+ expect:
+ CrossOriginRedirectInterceptor.isSameOrigin(
+ HttpUrl.parse(url1),
+ HttpUrl.parse(url2)
+ ) == expected
+
+ where:
+ url1 | url2 | expected
+ 'http://host/path1' | 'http://host/path2' | true
+ 'http://host:4440/path1' | 'http://host:4440/path2' | true
+ 'http://host/path' | 'https://host/path' | false
+ 'http://host:4440/path' | 'http://host:4441/path' | false
+ 'http://host1/path' | 'http://host2/path' | false
+ 'http://myserver.example.com:4440/api' | 'http://myserver.example.com/api' | false
+ }
+}