Skip to content

Commit 5cb2a80

Browse files
committed
Added more tests and specify comments a bit more
1 parent f332a54 commit 5cb2a80

File tree

2 files changed

+82
-23
lines changed

2 files changed

+82
-23
lines changed

core/src/main/java/jenkins/security/csp/AvatarContributor.java

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ public class AvatarContributor implements Contributor {
4949
private static final Logger LOGGER = Logger.getLogger(AvatarContributor.class.getName());
5050

5151
private final Set<String> domains = ConcurrentHashMap.newKeySet();
52-
private final Set<String> allowedSources = ConcurrentHashMap.newKeySet();
52+
private final Set<String> allowedSources = ConcurrentHashMap.newKeySet();
5353

54-
@Override
55-
public void apply(CspBuilder cspBuilder) {
56-
domains.forEach(d -> cspBuilder.add("img-src", d));
57-
allowedSources.forEach(s -> cspBuilder.add("img-src", s));
58-
}
54+
@Override
55+
public void apply(CspBuilder cspBuilder) {
56+
domains.forEach(d -> cspBuilder.add("img-src", d));
57+
allowedSources.forEach(s -> cspBuilder.add("img-src", s));
58+
}
5959

6060
/**
6161
* Request addition of the domain of the specified URL to the allowed set of avatar image domains.
@@ -94,20 +94,21 @@ public static void allow(@CheckForNull String url) {
9494
* @param url The full avatar image URL to allow.
9595
*/
9696
@SuppressWarnings("unused")
97-
public static void allowUrl(@CheckForNull String url) {
98-
String normalized = normalizeUrl(url);
97+
public static void allowUrl(@CheckForNull String url) {
98+
AvatarContributor self = ExtensionList.lookupSingleton(AvatarContributor.class);
9999

100-
if (normalized == null) {
101-
LOGGER.log(Level.FINE, "Skipping invalid or unsupported avatar URL: " + url);
102-
return;
103-
}
100+
if (addNormalizedUrl(url, self.allowedSources)) {
101+
LOGGER.log(Level.CONFIG, "Adding allowed avatar URL: {0}", url);
102+
}
103+
}
104104

105-
if (ExtensionList.lookupSingleton(AvatarContributor.class).allowedSources.add(normalized)) {
106-
LOGGER.log(Level.CONFIG, "Adding allowed avatar URL: " + normalized + " (from: " + url + ")");
107-
} else {
108-
LOGGER.log(Level.FINEST, "Skipped adding duplicate allowed url: " + normalized + " (from: " + url + ")");
109-
}
105+
static boolean addNormalizedUrl(@CheckForNull String url, Set<String> target) {
106+
String normalized = normalizeUrl(url);
107+
if (normalized == null) {
108+
return false;
110109
}
110+
return target.add(normalized);
111+
}
111112

112113
/**
113114
* Utility method extracting the domain specification for CSP fetch directives from a specified URL.
@@ -157,14 +158,33 @@ public static String extractDomainFromUrl(@CheckForNull String url) {
157158
/**
158159
* Normalizes a URL for use in Content-Security-Policy.
159160
* <p>
160-
* This method performs several steps:
161+
* This method validates and canonicalizes the URL to ensure it is safe for use in a CSP header.
161162
* </p>
162163
* <ul>
163-
* <li>Only http or https are accepted.</li>
164-
* <li>Removes embedded credentials for security.</li>
165-
* <li>Converts IDN to ASCII.</li>
166-
* <li>Normalizes IPv6 addresses.</li>
167-
* <li>Removes default ports.</li>
164+
* <li>
165+
* <strong>Accepts only http and https schemes</strong> -
166+
* Avatar images are fetched over the network. Other schemes such as file, data, or JavaScript are either unsafe
167+
* or meaningless in a CSP img-src context.
168+
* </li>
169+
* <li>
170+
* <strong>Rejects URLs with embedded credentials</strong> —
171+
* URLs containing user:password@host can accidentally expose credentials in logs or browser requests. Such URLs
172+
* are then rejected.
173+
* </li>
174+
* <li>
175+
* <strong>Converts IDN to ASCII</strong> —
176+
* Browser internally normalize hostnames to their ASCII representation. Normalizing here ensures Jenkins
177+
* generates CSP entries that match browser behavior consistently.
178+
* </li>
179+
* <li>
180+
* <strong>Normalizes IPv6 literals</strong> —
181+
* IPv6 addresses must be enclosed in square brackets in URLs.
182+
* </li>
183+
* <li>
184+
* <strong>Removes default ports</strong> —
185+
* Ports 80 (HTTP) and 443 (HTTPS) are removed. Removing them avoids duplicate CSP entries that differ only
186+
* by the presence of a default port.
187+
* </li>
168188
* </ul>
169189
*
170190
* @param url The raw input URL.

core/src/test/java/jenkins/security/csp/AvatarContributorTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
import static jenkins.security.csp.AvatarContributor.extractDomainFromUrl;
44
import static org.hamcrest.MatcherAssert.assertThat;
5+
import static org.hamcrest.Matchers.contains;
56
import static org.hamcrest.Matchers.containsString;
7+
import static org.hamcrest.Matchers.empty;
68
import static org.hamcrest.Matchers.is;
79
import static org.hamcrest.Matchers.nullValue;
810
import static org.hamcrest.Matchers.startsWith;
911

12+
import java.util.HashSet;
13+
import java.util.Set;
1014
import org.junit.jupiter.api.Test;
1115
import org.jvnet.hudson.test.For;
1216

@@ -144,17 +148,52 @@ void testNormalizeUrl_KeepsNonDefaultPort() {
144148
);
145149
}
146150

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

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

168+
@Test
169+
void testAddNormalizedUrl_AddsValidUrl() {
170+
Set<String> set = new HashSet<>();
171+
172+
boolean added = AvatarContributor.addNormalizedUrl("https://example.com/a.png", set);
173+
174+
assertThat(added, is(true));
175+
assertThat(set, contains("https://example.com/a.png"));
176+
}
177+
178+
@Test
179+
void testAddNormalizedUrl_RejectsInvalidUrl() {
180+
Set<String> set = new HashSet<>();
181+
182+
boolean added = AvatarContributor.addNormalizedUrl("ftp://example.com/a.png", set);
183+
184+
assertThat(added, is(false));
185+
assertThat(set, empty());
186+
}
187+
188+
@Test
189+
void testAddNormalizedUrl_Deduplicates() {
190+
Set<String> set = new HashSet<>();
191+
192+
AvatarContributor.addNormalizedUrl("https://example.com/a.png", set);
193+
boolean addedAgain = AvatarContributor.addNormalizedUrl("https://example.com/a.png", set);
194+
195+
assertThat(addedAgain, is(false));
196+
assertThat(set.size(), is(1));
197+
}
198+
160199
}

0 commit comments

Comments
 (0)