Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
121 changes: 117 additions & 4 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,11 +49,13 @@
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));
}
@Override
public void apply(CspBuilder cspBuilder) {
domains.forEach(d -> cspBuilder.add("img-src", d));
allowedSources.forEach(s -> cspBuilder.add("img-src", s));
}

/**
* Request addition of the domain of the specified URL to the allowed set of avatar image domains.
Expand Down Expand Up @@ -84,6 +87,28 @@
}
}

/**
* 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) {
String normalized = normalizeUrl(url);

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

if (ExtensionList.lookupSingleton(AvatarContributor.class).allowedSources.add(normalized)) {
LOGGER.log(Level.CONFIG, "Adding allowed avatar URL: " + normalized + " (from: " + url + ")");
} else {
LOGGER.log(Level.FINEST, "Skipped adding duplicate allowed url: " + normalized + " (from: " + url + ")");
}
}

Check warning on line 110 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-110 are not covered by tests

/**
* 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 +153,92 @@
return null;
}
}

/**
* Normalizes a URL for use in Content-Security-Policy.
* <p>
* This method performs several steps:
* </p>
* <ul>
* <li>Only http or https are accepted.</li>
* <li>Removes embedded credentials for security.</li>
* <li>Converts IDN to ASCII.</li>
* <li>Normalizes IPv6 addresses.</li>
* <li>Removes default ports.</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 175 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 175 is only partially covered, one branch is missing
return null;

Check warning on line 176 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 176 is not covered by tests
}
try {
URI uri = new URI(url);

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

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

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

Check warning on line 200 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 199-200 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 208 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 208 is only partially covered, one branch is missing
return null;

Check warning on line 209 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 209 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 223 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 223 is only partially covered, one branch is missing
(scheme.equals("https") && port == 443);

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

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 line

Line 230 is not covered by tests
}

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

Check warning on line 234 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 234 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 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 238-240 are not covered by tests
}
}

}
72 changes: 72 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,8 +2,10 @@

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

import org.junit.jupiter.api.Test;
import org.jvnet.hudson.test.For;
Expand Down Expand Up @@ -85,4 +87,74 @@ 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")
);
}

@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"));
}

@Test
void testNormalizeUrl_IdnIsConvertedToAscii() {
String normalized = AvatarContributor.normalizeUrl("https://例え.テスト/a.png");
assertThat(normalized, startsWith("https://xn--r8jz45g.xn--zckzah"));
}

}
Loading