Skip to content
Merged
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
24 changes: 24 additions & 0 deletions rd-api-client/src/main/java/org/rundeck/client/RundeckClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -105,6 +110,7 @@ public Builder<A> 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;
}

Expand Down Expand Up @@ -179,6 +185,16 @@ public Builder<A> 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<A> allowCrossOriginRedirect(final boolean allow) {
return accept(RundeckClient::configAllowCrossOriginRedirect, allow);
}

public Builder<A> tokenAuth(final String authToken) {
buildTokenAuth(okhttp, baseUrl, authToken);
return this;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
public class CrossOriginRedirectInterceptor implements Interceptor {

private static final Set<Integer> 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();
}
}
18 changes: 18 additions & 0 deletions rd-cli-tool/src/main/java/org/rundeck/client/tool/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
}
}
Loading
Loading