Skip to content

Commit fd6f9fe

Browse files
Make AvatarContributorallow more targeted
1 parent 1366fa9 commit fd6f9fe

File tree

1 file changed

+87
-14
lines changed

1 file changed

+87
-14
lines changed

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

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,43 +47,114 @@
4747
public class AvatarContributor implements Contributor {
4848
private static final Logger LOGGER = Logger.getLogger(AvatarContributor.class.getName());
4949

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

5252
@Override
5353
public void apply(CspBuilder cspBuilder) {
54-
domains.forEach(d -> cspBuilder.add("img-src", d));
54+
allowedSources.forEach(source -> cspBuilder.add("img-src", source));
5555
}
5656

5757
/**
58-
* Request addition of the domain of the specified URL to the allowed set of avatar image domains.
58+
* Request addition of the complete URL to the allowed set of avatar image sources.
5959
* <p>
60-
* This is a utility method intended to accept any avatar URL from an undetermined, but trusted (for images) domain.
61-
* If the specified URL is not {@code null}, has a host, and {@code http} or {@code https} scheme, its domain will
62-
* be added to the set of allowed domains.
60+
* Unlike the previous implementation that extracted only the domain, this now
61+
* allowlists the exact URL, providing more granular security control.
6362
* </p>
6463
* <p>
6564
* <strong>Important:</strong> Only implementations restricting specification of avatar URLs to at least somewhat
66-
* privileged users to should invoke this method, for example users with at least {@link hudson.model.Item#CONFIGURE}
65+
* privileged users should invoke this method, for example users with at least {@link hudson.model.Item#CONFIGURE}
6766
* permission. Note that this guidance may change over time and require implementation changes.
6867
* </p>
68+
* <p>
69+
* <strong>Note:</strong> Due to CSP header length limitations (approximately 8KB), administrators should
70+
* monitor the total number of allowlisted URLs. Issue #23887 provides mechanisms to handle extremely long
71+
* CSP headers.
72+
* </p>
6973
*
70-
* @param url The avatar image URL whose domain should be added to the list of allowed domains
74+
* @param url The complete avatar image URL that should be allowlisted
7175
*/
7276
public static void allow(@CheckForNull String url) {
73-
String domain = extractDomainFromUrl(url);
77+
String normalizedUrl = normalizeUrl(url);
7478

75-
if (domain == null) {
76-
LOGGER.log(Level.FINE, "Skipping null domain in avatar URL: " + url);
79+
if (normalizedUrl == null) {
80+
LOGGER.log(Level.FINE, "Skipping invalid or null URL: " + url);
7781
return;
7882
}
7983

80-
if (ExtensionList.lookupSingleton(AvatarContributor.class).domains.add(domain)) {
81-
LOGGER.log(Level.CONFIG, "Adding domain '" + domain + "' from avatar URL: " + url);
84+
if (ExtensionList.lookupSingleton(AvatarContributor.class).allowedSources.add(normalizedUrl)) {
85+
LOGGER.log(Level.CONFIG, "Adding URL '" + normalizedUrl + "' to avatar allowlist");
8286
} else {
83-
LOGGER.log(Level.FINEST, "Skipped adding duplicate domain '" + domain + "' from avatar URL: " + url);
87+
LOGGER.log(Level.FINEST, "Skipped adding duplicate URL '" + normalizedUrl + "'");
8488
}
8589
}
8690

91+
/**
92+
* Normalizes and validates a URL for CSP inclusion.
93+
* Only http and https URLs with a valid host are accepted.
94+
*
95+
* @param url the URL to normalize
96+
* @return the normalized URL suitable for CSP, or null if invalid
97+
*/
98+
@CheckForNull
99+
private static String normalizeUrl(@CheckForNull String url) {
100+
if (url == null) {
101+
return null;
102+
}
103+
try {
104+
final URI uri = new URI(url);
105+
final String host = uri.getHost();
106+
if (host == null) {
107+
LOGGER.log(Level.FINER, "Ignoring URI without host: " + url);
108+
return null;
109+
}
110+
// Reject URLs with embedded credentials
111+
if (uri.getUserInfo() != null) {
112+
LOGGER.log(Level.FINER, "Ignoring URI with user info: " + url);
113+
return null;
114+
}
115+
String scheme = uri.getScheme();
116+
if (scheme == null) {
117+
LOGGER.log(Level.FINER, "Ignoring URI without scheme: " + url);
118+
return null;
119+
}
120+
scheme = scheme.toLowerCase(Locale.ROOT);
121+
if (!scheme.equals("http") && !scheme.equals("https")) {
122+
LOGGER.log(Level.FINER, "Ignoring URI with unsupported scheme: " + url);
123+
return null;
124+
}
125+
126+
// ToASCII for IDNs
127+
final String asciiHost = java.net.IDN.toASCII(host.toLowerCase(Locale.ROOT));
128+
129+
StringBuilder normalized = new StringBuilder(scheme).append("://");
130+
// IPv6 needs brackets when serialized
131+
if (asciiHost.contains(":") && !asciiHost.startsWith("[")) {
132+
normalized.append("[").append(asciiHost).append("]");
133+
} else {
134+
normalized.append(asciiHost);
135+
}
136+
137+
final int port = uri.getPort();
138+
// omit default ports
139+
if (port != -1) {
140+
if (!(scheme.equals("http") && port == 80) && !(scheme.equals("https") && port == 443)) {
141+
normalized.append(":").append(port);
142+
}
143+
}
144+
145+
// preserve encoded path (raw) to avoid surprising decoding differences
146+
final String rawPath = uri.getRawPath();
147+
if (rawPath != null && !rawPath.isEmpty()) {
148+
normalized.append(rawPath);
149+
}
150+
return normalized.toString();
151+
} catch (URISyntaxException | IllegalArgumentException e) {
152+
LOGGER.log(Level.FINE, "Failed to parse avatar URI: " + url, e);
153+
return null;
154+
}
155+
}
156+
157+
87158
/**
88159
* Utility method extracting the domain specification for CSP fetch directives from a specified URL.
89160
* If the specified URL is not {@code null}, has a host, and {@code http} or {@code https} scheme, this method
@@ -93,7 +164,9 @@ public static void allow(@CheckForNull String url) {
93164
*
94165
* @param url the URL
95166
* @return the domain from the specified URL, or {@code null} if the URL does not satisfy the stated conditions
167+
* @deprecated Use domain-based allowlisting only when necessary. Prefer URL-specific allowlisting via {@link #allow(String)}
96168
*/
169+
@Deprecated
97170
@CheckForNull
98171
public static String extractDomainFromUrl(@CheckForNull String url) {
99172
if (url == null) {

0 commit comments

Comments
 (0)