Skip to content
Open
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
31 changes: 29 additions & 2 deletions core/src/main/java/jenkins/util/ClientHttpRedirect.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@

package jenkins.util;

import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Util;
import jakarta.servlet.ServletException;
import java.io.IOException;
import java.util.Locale;
import java.util.Objects;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.StaplerRequest2;
import org.kohsuke.stapler.StaplerResponse2;
Expand All @@ -36,14 +39,38 @@
* Unlike {@link org.kohsuke.stapler.HttpRedirect}, this implements a client-side redirect (using meta tag and/or JavaScript).
* This allows the redirect to work even when Content Security Policy is enforced in Chrome
* (which applies {@code form-action} to redirects after form submission).
* <p>
* For security reasons, only HTTP/HTTPS URLs and relative paths are allowed.
* Attempts to redirect to other schemes (e.g., {@code javascript:}, {@code data:}, {@code file:})
* will result in a security warning page instead of performing the redirect.
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/form-action">MDN documentation on form-action</a>
* @see <a href="https://github.com/w3c/webappsec-csp/issues/8">Content Security Policy issue discussing this behavior</a>
* @since 2.550
*/
public record ClientHttpRedirect(String redirectUrl) implements HttpResponse {
public record ClientHttpRedirect(@NonNull String redirectUrl) implements HttpResponse {

public ClientHttpRedirect {
Objects.requireNonNull(redirectUrl);
}

private static boolean isSafeToRedirectTo(@NonNull String url) {
if (Util.isSafeToRedirectTo(url)) {
return true;
}

String urlLower = url.toLowerCase(Locale.ENGLISH);
return urlLower.startsWith("http://") || urlLower.startsWith("https://");
}

@Override
public void generateResponse(StaplerRequest2 req, StaplerResponse2 rsp, Object o) throws IOException, ServletException {
if (!isSafeToRedirectTo(redirectUrl)) {
throw hudson.util.HttpResponses.error(403,
"Unsafe redirect blocked: Jenkins only allows redirects to HTTP/HTTPS URLs or relative paths. "
+ "Blocked URL: " + Util.escape(redirectUrl));
}

rsp.setContentType("text/html;charset=UTF-8");
Util.printRedirect(req.getContextPath(), redirectUrl, redirectUrl, rsp.getWriter());
Util.printRedirect(req.getContextPath(), redirectUrl, Util.escape(redirectUrl), rsp.getWriter());
}
}
164 changes: 164 additions & 0 deletions test/src/test/java/jenkins/util/ClientHttpRedirectTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package jenkins.util;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.ByteArrayOutputStream;
import java.io.PrintWriter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.kohsuke.stapler.StaplerRequest2;
import org.kohsuke.stapler.StaplerResponse2;
import org.mockito.Mockito;

/**
* Tests for {@link ClientHttpRedirect} URL validation.
*/
class ClientHttpRedirectTest {

@Test
void testNullRedirectUrlBlockedAtConstruction() {
assertThrows(NullPointerException.class, () -> new ClientHttpRedirect(null));
}

/**
* Test that HTTP URLs pass validation and generate response.
*/
@ParameterizedTest
@ValueSource(strings = {
"http://www.example.com/page",
"https://www.jenkins.io/doc/",
"HTTP://EXAMPLE.COM",
"HTTPS://JENKINS.IO"
})
void testAllowedUrlSchemes(String url) throws Exception {
ClientHttpRedirect redirect = new ClientHttpRedirect(url);
StaplerRequest2 req = Mockito.mock(StaplerRequest2.class);
StaplerResponse2 rsp = Mockito.mock(StaplerResponse2.class);

ByteArrayOutputStream baos = new ByteArrayOutputStream();
PrintWriter writer = new PrintWriter(baos);

Mockito.when(rsp.getWriter()).thenReturn(writer);
Mockito.when(req.getContextPath()).thenReturn("");

redirect.generateResponse(req, rsp, null);

writer.flush();
String output = baos.toString();
assertTrue(output.length() > 0);
}

/**
* Test that relative URLs are allowed.
*/
@ParameterizedTest
@ValueSource(strings = {
"manage/configure",
"/jenkins/manage",
"/../config",
"foo/bar"
})
void testRelativeUrlsAllowed(String url) throws Exception {
ClientHttpRedirect redirect = new ClientHttpRedirect(url);
StaplerRequest2 req = Mockito.mock(StaplerRequest2.class);
StaplerResponse2 rsp = Mockito.mock(StaplerResponse2.class);

ByteArrayOutputStream baos = new ByteArrayOutputStream();
PrintWriter writer = new PrintWriter(baos);

Mockito.when(rsp.getWriter()).thenReturn(writer);
Mockito.when(req.getContextPath()).thenReturn("");

redirect.generateResponse(req, rsp, null);

writer.flush();
String output = baos.toString();
assertTrue(output.length() > 0);
}

/**
* Test that javascript: URLs are blocked.
*/
@Test
void testJavaScriptUrlBlocked() {
ClientHttpRedirect redirect = new ClientHttpRedirect("javascript:alert('XSS')");
StaplerRequest2 req = Mockito.mock(StaplerRequest2.class);
StaplerResponse2 rsp = Mockito.mock(StaplerResponse2.class);

Exception exception = assertThrows(Exception.class, () -> redirect.generateResponse(req, rsp, null));
assertTrue(exception.getMessage().contains("Unsafe redirect blocked"));
}

/**
* Test that data: URLs are blocked.
*/
@Test
void testDataUrlBlocked() {
ClientHttpRedirect redirect = new ClientHttpRedirect("data:text/html,<script>alert('XSS')</script>");
StaplerRequest2 req = Mockito.mock(StaplerRequest2.class);
StaplerResponse2 rsp = Mockito.mock(StaplerResponse2.class);

Exception exception = assertThrows(Exception.class, () -> redirect.generateResponse(req, rsp, null));
assertTrue(exception.getMessage().contains("Unsafe redirect blocked"));
}

/**
* Test that file: URLs are blocked.
*/
@Test
void testFileUrlBlocked() {
ClientHttpRedirect redirect = new ClientHttpRedirect("file:///etc/passwd");
StaplerRequest2 req = Mockito.mock(StaplerRequest2.class);
StaplerResponse2 rsp = Mockito.mock(StaplerResponse2.class);

Exception exception = assertThrows(Exception.class, () -> redirect.generateResponse(req, rsp, null));
assertTrue(exception.getMessage().contains("Unsafe redirect blocked"));
}

/**
* Test that custom scheme URLs are blocked.
*/
@ParameterizedTest
@ValueSource(strings = {
"custom-scheme://malicious",
"ftp://server/file",
"telnet://host:23"
})
void testCustomSchemesBlocked(String url) {
ClientHttpRedirect redirect = new ClientHttpRedirect(url);
StaplerRequest2 req = Mockito.mock(StaplerRequest2.class);
StaplerResponse2 rsp = Mockito.mock(StaplerResponse2.class);

Exception exception = assertThrows(Exception.class, () -> redirect.generateResponse(req, rsp, null));
assertTrue(exception.getMessage().contains("Unsafe redirect blocked"));
}

/**
* Test that mixed case HTTP/HTTPS URLs are allowed.
*/
@ParameterizedTest
@ValueSource(strings = {
"HtTp://www.example.com",
"HtTpS://www.jenkins.io",
"hTTp://test.com"
})
void testMixedCaseHttpAllowed(String url) throws Exception {
ClientHttpRedirect redirect = new ClientHttpRedirect(url);
StaplerRequest2 req = Mockito.mock(StaplerRequest2.class);
StaplerResponse2 rsp = Mockito.mock(StaplerResponse2.class);

ByteArrayOutputStream baos = new ByteArrayOutputStream();
PrintWriter writer = new PrintWriter(baos);

Mockito.when(rsp.getWriter()).thenReturn(writer);
Mockito.when(req.getContextPath()).thenReturn("");

redirect.generateResponse(req, rsp, null);

writer.flush();
String output = baos.toString();
assertTrue(output.length() > 0);
}
}
Loading