Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
133 changes: 133 additions & 0 deletions core/src/main/java/jenkins/security/csp/AvatarContributor.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import edu.umd.cs.findbugs.annotations.CheckForNull;
import hudson.Extension;
import hudson.ExtensionList;
import java.net.IDN;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
Expand All @@ -48,10 +49,12 @@
private static final Logger LOGGER = Logger.getLogger(AvatarContributor.class.getName());

private final Set<String> domains = ConcurrentHashMap.newKeySet();
private final Set<String> allowedSources = ConcurrentHashMap.newKeySet();

@Override
public void apply(CspBuilder cspBuilder) {
domains.forEach(d -> cspBuilder.add("img-src", d));
allowedSources.forEach(s -> cspBuilder.add("img-src", s));
}

/**
Expand Down Expand Up @@ -84,6 +87,29 @@
}
}

/**
* Request addition of a specific URL to the allowed set of avatar sources.
* This method allows external plugins to allowlist specific image URLs so they can be loaded via CSP.
*
* @param url The full avatar image URL to allow.
*/
@SuppressWarnings("unused")
public static void allowUrl(@CheckForNull String url) {
AvatarContributor self = ExtensionList.lookupSingleton(AvatarContributor.class);

if (addNormalizedUrl(url, self.allowedSources)) {
LOGGER.log(Level.CONFIG, "Adding allowed avatar URL: {0}", url);
}
}

Check warning on line 103 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 98-103 are not covered by tests

static boolean addNormalizedUrl(@CheckForNull String url, Set<String> target) {
String normalized = normalizeUrl(url);
if (normalized == null) {
return false;
}
return target.add(normalized);
}

/**
* Utility method extracting the domain specification for CSP fetch directives from a specified URL.
* If the specified URL is not {@code null}, has a host, and {@code http} or {@code https} scheme, this method
Expand Down Expand Up @@ -128,4 +154,111 @@
return null;
}
}

/**
* Normalizes a URL for use in Content-Security-Policy.
* <p>
* This method validates and canonicalizes the URL to ensure it is safe for use in a CSP header.
* </p>
* <ul>
* <li>
* <strong>Accepts only http and https schemes</strong> -
* Avatar images are fetched over the network. Other schemes such as file, data, or JavaScript are either unsafe
* or meaningless in a CSP img-src context.
* </li>
* <li>
* <strong>Rejects URLs with embedded credentials</strong> —
* URLs containing user:password@host can accidentally expose credentials in logs or browser requests. Such URLs
* are then rejected.
* </li>
* <li>
* <strong>Converts IDN to ASCII</strong> —
* Browser internally normalize hostnames to their ASCII representation. Normalizing here ensures Jenkins
* generates CSP entries that match browser behavior consistently.
* </li>
* <li>
* <strong>Normalizes IPv6 literals</strong> —
* IPv6 addresses must be enclosed in square brackets in URLs.
* </li>
* <li>
* <strong>Removes default ports</strong> —
* Ports 80 (HTTP) and 443 (HTTPS) are removed. Removing them avoids duplicate CSP entries that differ only
* by the presence of a default port.
* </li>
* </ul>
*
* @param url The raw input URL.
* @return A canonical, safe string representation of the URL, or null if the URL is invalid or unsafe.
*/
@CheckForNull
public static String normalizeUrl(@CheckForNull String url) {
if (url == null) {

Check warning on line 195 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 195 is only partially covered, one branch is missing
return null;

Check warning on line 196 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 196 is not covered by tests
}
try {
URI uri = new URI(url);

String scheme = uri.getScheme();
if (scheme == null) {

Check warning on line 202 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 202 is only partially covered, one branch is missing
LOGGER.log(Level.FINER, "Ignoring URI without scheme: " + url);
return null;

Check warning on line 204 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 203-204 are not covered by tests
}
scheme = scheme.toLowerCase(Locale.ROOT);
if (!scheme.equals("http") && !scheme.equals("https")) {
LOGGER.log(Level.FINER, "Ignoring URI with unsupported scheme: " + url);
return null;
}

if (uri.getUserInfo() != null && !uri.getUserInfo().isEmpty()) {

Check warning on line 212 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 212 is only partially covered, one branch is missing
LOGGER.log(Level.FINER, "Ignoring URI with embedded credentials: " + url);
return null;
}

String rawAuthority = uri.getRawAuthority();
if (rawAuthority == null) {

Check warning on line 218 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 218 is only partially covered, one branch is missing
LOGGER.log(Level.FINER, "Ignoring URI without authority: " + url);
return null;

Check warning on line 220 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 219-220 are not covered by tests
}

String host;
int port = uri.getPort();

if (rawAuthority.startsWith("[")) {
int end = rawAuthority.indexOf(']');
if (end == -1) {

Check warning on line 228 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 228 is only partially covered, one branch is missing
return null;

Check warning on line 229 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 229 is not covered by tests
}
host = rawAuthority.substring(1, end);
} else {
int colon = rawAuthority.indexOf(':');
host = colon >= 0 ? rawAuthority.substring(0, colon) : rawAuthority;
}

String asciiHost = IDN.toASCII(host).toLowerCase(Locale.ROOT);
boolean ipv6 = asciiHost.contains(":");
String hostPart = ipv6 ? "[" + asciiHost + "]" : asciiHost;

boolean omitPort =
port == -1 ||
(scheme.equals("http") && port == 80) ||

Check warning on line 243 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 243 is only partially covered, one branch is missing
(scheme.equals("https") && port == 443);

Check warning on line 244 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 244 is only partially covered, one branch is missing

String portPart = omitPort ? "" : ":" + port;

String path = uri.getRawPath();
if (path == null || path.isEmpty()) {

Check warning on line 249 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 249 is only partially covered, 2 branches are missing
path = "/";

Check warning on line 250 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 250 is not covered by tests
}

String query = uri.getRawQuery();
String queryPart = query == null || query.isEmpty() ? "" : "?" + query;

Check warning on line 254 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 254 is only partially covered, one branch is missing

return scheme + "://" + hostPart + portPart + path + queryPart;

} catch (URISyntaxException | IllegalArgumentException e) {
LOGGER.log(Level.FINE, "Failed to normalize avatar URI: " + url, e);
return null;

Check warning on line 260 in core/src/main/java/jenkins/security/csp/AvatarContributor.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 258-260 are not covered by tests
}
}

}
111 changes: 111 additions & 0 deletions core/src/test/java/jenkins/security/csp/AvatarContributorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@

import static jenkins.security.csp.AvatarContributor.extractDomainFromUrl;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.startsWith;

import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.jvnet.hudson.test.For;

Expand Down Expand Up @@ -85,4 +91,109 @@ void testExtractDomainFromUrl_Ipv4WithPort() {
void testExtractDomainFromUrl_CaseInsensitivity() {
assertThat(extractDomainFromUrl("hTTps://EXAMPLE.com/path/to/avatar.png"), is("https://example.com"));
}

@Test
void testNormalizeUrl_BasicHttps() {
assertThat(
AvatarContributor.normalizeUrl("https://example.com/avatars/u1.png"), is("https://example.com/avatars/u1.png")
);
}

@Test
void testNormalizeUrl_PreservesEncodedPathAndQuery() {
assertThat(
AvatarContributor.normalizeUrl("https://example.com/a%20b.png?size=64"), is("https://example.com/a%20b.png?size=64")
);
}

@Test
void testNormalizeUrl_StripsFragment() {
assertThat(
AvatarContributor.normalizeUrl("https://example.com/a.png#fragment"), is("https://example.com/a.png")
);
}

@Test
void testNormalizeUrl_RejectsUserInfo() {
assertThat(
AvatarContributor.normalizeUrl("https://user:pass@example.com/a.png"), is(nullValue())
);
}

@Test
void testNormalizeUrl_RejectsUnsupportedScheme() {
assertThat(
AvatarContributor.normalizeUrl("ftp://example.com/a.png"), is(nullValue())
);
}

@Test
void testNormalizeUrl_OmitsDefaultHttpsPort() {
assertThat(
AvatarContributor.normalizeUrl("https://example.com:443/a.png"), is("https://example.com/a.png")
);
}

@Test
void testNormalizeUrl_OmitsDefaultHttpPort() {
assertThat(
AvatarContributor.normalizeUrl("http://example.com:80/a.png"), is("http://example.com/a.png")
);
}

@Test
void testNormalizeUrl_KeepsNonDefaultPort() {
assertThat(
AvatarContributor.normalizeUrl("https://example.com:8443/a.png"), is("https://example.com:8443/a.png")
);
}

// This test does not assert the exact full string. Its purpose is to verify that IPv6 literals are correctly bracketed,
// while the path and other components are covered by separate tests.
@Test
void testNormalizeUrl_Ipv6HostIsBracketed() {
String normalized = AvatarContributor.normalizeUrl("https://[2001:db8::1]/a.png");
assertThat(normalized, startsWith("https://[2001:db8::1]"));
assertThat(normalized, containsString("/a.png"));
}

// Verifies that the Unicode hostname is converted to its ASCII representation while preserving the
// rest of the URL structure.
@Test
void testNormalizeUrl_IdnIsConvertedToAscii() {
String normalized = AvatarContributor.normalizeUrl("https://例え.テスト/a.png");
assertThat(normalized, startsWith("https://xn--r8jz45g.xn--zckzah"));
}

@Test
void testAddNormalizedUrl_AddsValidUrl() {
Set<String> set = new HashSet<>();

boolean added = AvatarContributor.addNormalizedUrl("https://example.com/a.png", set);

assertThat(added, is(true));
assertThat(set, contains("https://example.com/a.png"));
}

@Test
void testAddNormalizedUrl_RejectsInvalidUrl() {
Set<String> set = new HashSet<>();

boolean added = AvatarContributor.addNormalizedUrl("ftp://example.com/a.png", set);

assertThat(added, is(false));
assertThat(set, empty());
}

@Test
void testAddNormalizedUrl_Deduplicates() {
Set<String> set = new HashSet<>();

AvatarContributor.addNormalizedUrl("https://example.com/a.png", set);
boolean addedAgain = AvatarContributor.addNormalizedUrl("https://example.com/a.png", set);

assertThat(addedAgain, is(false));
assertThat(set.size(), is(1));
}

}
Loading