diff --git a/rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java b/rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java index 020e72cc..239d42c1 100644 --- a/rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java +++ b/rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java @@ -61,6 +61,11 @@ public class RundeckClient { * If true, allow API version to be automatically degraded when unsupported version is detected */ public static final String RD_API_DOWNGRADE = "RD_API_DOWNGRADE"; + /** + * If true, allow cross-origin HTTP redirects to be followed with authentication credentials. + * Default is false: cross-origin redirects are blocked to prevent credential exfiltration. + */ + public static final String ENV_ALLOW_CROSS_ORIGIN_REDIRECT = "RD_ALLOW_CROSS_ORIGIN_REDIRECT"; public static final int INSECURE_SSL_LOGGING = 2; public static final long DEFAULT_READ_TIMEOUT_SECONDS = 10 * 60L; public static final long DEFAULT_CONN_TIMEOUT_SECONDS = 2 * 60L; @@ -105,6 +110,7 @@ public Builder config(RdClientConfig config) { insecureSSLHostname(config.getBool(ENV_INSECURE_SSL_HOSTNAME, false)); alternateSSLHostname(config.getString(ENV_ALT_SSL_HOSTNAME, null)); allowVersionDowngrade(config.getBool(RD_API_DOWNGRADE, false)); + allowCrossOriginRedirect(config.getBool(ENV_ALLOW_CROSS_ORIGIN_REDIRECT, false)); return this; } @@ -179,6 +185,16 @@ public Builder allowVersionDowngrade(final boolean allow) { return this; } + /** + * Configure whether cross-origin redirects are allowed. + * + * @param allow when {@code true}, cross-origin redirects are followed with credentials + * (original behaviour); when {@code false} (default), they are blocked + */ + public Builder allowCrossOriginRedirect(final boolean allow) { + return accept(RundeckClient::configAllowCrossOriginRedirect, allow); + } + public Builder 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 + } +}