4747public 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