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
144 changes: 144 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,37 @@
}
}

/**
* 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);

String normalized = normalizeUrl(url);
if (normalized == null) {
LOGGER.log(Level.FINE, "Skipping invalid or unsupported avatar URL: {0}", url);
return;
}

if (self.allowedSources.add(normalized)) {

Check warning on line 106 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 106 is only partially covered, one branch is missing
LOGGER.log(Level.CONFIG, "Adding allowed avatar URL: {0} (normalized: {1})", new Object[] { url, normalized });
} else {
LOGGER.log(Level.FINEST, "Skipped adding duplicate allowed avatar URL: {0} (normalized: {1})", new Object[] { url, normalized });

Check warning on line 109 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 109 is 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 +162,114 @@
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> —
* Browsers 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 203 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 203 is only partially covered, one branch is missing
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 line

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

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

Check warning on line 210 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 210 is only partially covered, one branch is missing
LOGGER.log(Level.FINER, "Ignoring URI without scheme: " + url);
return null;

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

Not covered lines

Lines 211-212 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 220 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 220 is only partially covered, one branch is missing
LOGGER.log(Level.FINER, "Ignoring URI with embedded credentials: " + url);
return null;
}

// Used rawAuthority instead of uri.getHost() so we can strictly validate and canonicalize the authority
// component for CSP usage, including explicit handling of IPv6 literals and malformed authorities.
String rawAuthority = uri.getRawAuthority();
if (rawAuthority == null) {

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
LOGGER.log(Level.FINER, "Ignoring URI without authority: " + url);
return null;

Check warning on line 230 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 229-230 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 238 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 238 is only partially covered, one branch is missing
LOGGER.log(Level.FINER, "Ignoring malformed IPv6 authority: {0}", url);
return null;

Check warning on line 240 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 239-240 are 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 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
(scheme.equals("https") && port == 443);

Check warning on line 255 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 255 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 260 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 260 is only partially covered, 2 branches are missing
path = "/";

Check warning on line 261 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 261 is not covered by tests
}

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

Check warning on line 265 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 265 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 271 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 269-271 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));
}

}
19 changes: 19 additions & 0 deletions test/src/test/java/jenkins/security/csp/AvatarContributorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,23 @@ void testAllow_CaseInsensitivity(JenkinsRule j) {
assertThat(loggerRule, recorded(Level.CONFIG, is("Adding domain 'https://avatars.example.com' from avatar URL: hTTps://AVATARS.example.com/user/avatar.png")));
assertThat(loggerRule, recorded(Level.FINEST, is("Skipped adding duplicate domain 'https://avatars.example.com' from avatar URL: HttPS://avatars.EXAMPLE.com/user/avatar.png")));
}

@Test
void testAllowUrlWithDefaults_ValidUrl(JenkinsRule j) {
AvatarContributor.allowUrl("https://avatars.example.com/user/avatar.png");

String csp = new CspBuilder().withDefaultContributions().build();

assertThat(csp, containsString("https://avatars.example.com/user/avatar.png"));
}

@Test
void testAllowUrlWithDefaults_InvalidUrl(JenkinsRule j) {
AvatarContributor.allowUrl("javascript:alert(1)");

String csp = new CspBuilder().withDefaultContributions().build();

assertThat(csp, not(containsString("javascript:alert(1)")));
}

}
Loading