Skip to content

Commit 7fa3de4

Browse files
henryjuclaude
andauthored
SCANJLIB-309 Add sonar.scanner.httpExtraHeaders support for custom auth schemes (#285)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 15b7de0 commit 7fa3de4

File tree

8 files changed

+247
-3
lines changed

8 files changed

+247
-3
lines changed

its/it-tests/src/test/java/com/sonar/scanner/lib/it/tools/CommandExecutor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545

4646
public class CommandExecutor {
4747
private static final Logger LOG = LoggerFactory.getLogger(CommandExecutor.class);
48-
private static final int TIMEOUT = 20_000;
48+
private static final int TIMEOUT = 120_000;
4949
private Path file;
5050

5151
private ByteArrayOutputStream logs;

lib/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@
6161
<artifactId>bcprov-jdk18on</artifactId>
6262
<version>1.83</version>
6363
</dependency>
64+
<dependency>
65+
<groupId>de.siegmar</groupId>
66+
<artifactId>fastcsv</artifactId>
67+
</dependency>
6468

6569
<!-- unit tests -->
6670
<dependency>

lib/src/main/java/org/sonarsource/scanner/lib/ScannerProperties.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,12 @@ private ScannerProperties() {
128128
* Java options to be used by the scanner-engine.
129129
*/
130130
public static final String SCANNER_JAVA_OPTS = "sonar.scanner.javaOpts";
131+
132+
/**
133+
* Extra HTTP headers to add to every request sent by the scanner bootstrapper, in RFC 4180 CSV
134+
* format: comma-separated {@code Name: Value} fields. Fields whose value contains a comma must
135+
* be quoted, e.g. {@code X-Auth: token,"X-Link: <url1>, <url2>"}.
136+
* The {@code User-Agent} header cannot be overridden via this property.
137+
*/
138+
public static final String SONAR_SCANNER_HTTP_EXTRA_HEADERS = "sonar.scanner.httpExtraHeaders";
131139
}

lib/src/main/java/org/sonarsource/scanner/lib/internal/http/HttpConfig.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919
*/
2020
package org.sonarsource.scanner.lib.internal.http;
2121

22+
import de.siegmar.fastcsv.reader.CsvReader;
2223
import java.net.InetSocketAddress;
2324
import java.net.Proxy;
2425
import java.nio.file.Files;
2526
import java.nio.file.Path;
2627
import java.nio.file.Paths;
2728
import java.time.Duration;
2829
import java.time.format.DateTimeParseException;
30+
import java.util.LinkedHashMap;
2931
import java.util.Map;
3032
import java.util.Objects;
3133
import java.util.Optional;
@@ -53,6 +55,7 @@
5355
import static org.sonarsource.scanner.lib.ScannerProperties.SONAR_SCANNER_PROXY_PORT;
5456
import static org.sonarsource.scanner.lib.ScannerProperties.SONAR_SCANNER_PROXY_USER;
5557
import static org.sonarsource.scanner.lib.ScannerProperties.SONAR_SCANNER_RESPONSE_TIMEOUT;
58+
import static org.sonarsource.scanner.lib.ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS;
5659
import static org.sonarsource.scanner.lib.ScannerProperties.SONAR_SCANNER_SKIP_JVM_SSL_CONFIG;
5760
import static org.sonarsource.scanner.lib.ScannerProperties.SONAR_SCANNER_SKIP_SYSTEM_TRUSTSTORE;
5861
import static org.sonarsource.scanner.lib.ScannerProperties.SONAR_SCANNER_SOCKET_TIMEOUT;
@@ -96,6 +99,9 @@ public class HttpConfig {
9699
private final String proxyPassword;
97100
private final String userAgent;
98101
private final boolean skipSystemTrustMaterial;
102+
private final Map<String, String> extraHeaders;
103+
private final boolean hasCustomAuthorization;
104+
private final boolean hasCustomProxyAuthorization;
99105

100106
public HttpConfig(Map<String, String> bootstrapProperties, Path sonarUserHome, System2 system) {
101107
this.webApiBaseUrl = StringUtils.removeEnd(bootstrapProperties.get(ScannerProperties.HOST_URL), "/");
@@ -117,6 +123,9 @@ public HttpConfig(Map<String, String> bootstrapProperties, Path sonarUserHome, S
117123
this.proxyUser = loadProxyUser(bootstrapProperties);
118124
this.proxyPassword = loadProxyPassword(bootstrapProperties);
119125
this.skipSystemTrustMaterial = Boolean.parseBoolean(defaultIfBlank(bootstrapProperties.get(SONAR_SCANNER_SKIP_SYSTEM_TRUSTSTORE), "false"));
126+
this.extraHeaders = parseExtraHeaders(bootstrapProperties);
127+
this.hasCustomAuthorization = extraHeaders.keySet().stream().anyMatch("authorization"::equalsIgnoreCase);
128+
this.hasCustomProxyAuthorization = extraHeaders.keySet().stream().anyMatch("proxy-authorization"::equalsIgnoreCase);
120129
}
121130

122131
@CheckForNull
@@ -300,4 +309,58 @@ public String getProxyPassword() {
300309
public boolean skipSystemTruststore() {
301310
return skipSystemTrustMaterial;
302311
}
312+
313+
public Map<String, String> getExtraHeaders() {
314+
return extraHeaders;
315+
}
316+
317+
public boolean hasCustomAuthorization() {
318+
return hasCustomAuthorization;
319+
}
320+
321+
public boolean hasCustomProxyAuthorization() {
322+
return hasCustomProxyAuthorization;
323+
}
324+
325+
private static Map<String, String> parseExtraHeaders(Map<String, String> bootstrapProperties) {
326+
var rawValue = bootstrapProperties.get(SONAR_SCANNER_HTTP_EXTRA_HEADERS);
327+
if (rawValue == null || rawValue.isBlank()) {
328+
return Map.of();
329+
}
330+
var headers = new LinkedHashMap<String, String>();
331+
try (var reader = CsvReader.builder().ofCsvRecord(rawValue)) {
332+
for (var csvRecord : reader) {
333+
for (var field : csvRecord.getFields()) {
334+
var trimmed = field.trim();
335+
if (!trimmed.isEmpty()) {
336+
parseAndAddHeader(headers, trimmed);
337+
}
338+
}
339+
}
340+
} catch (IllegalArgumentException e) {
341+
throw e;
342+
} catch (Exception e) {
343+
throw new IllegalArgumentException("Failed to parse '" + SONAR_SCANNER_HTTP_EXTRA_HEADERS + "': " + e.getMessage(), e);
344+
}
345+
return Map.copyOf(headers);
346+
}
347+
348+
private static void parseAndAddHeader(Map<String, String> headers, String field) {
349+
int colonIdx = field.indexOf(':');
350+
if (colonIdx < 0) {
351+
throw new IllegalArgumentException(
352+
"Invalid HTTP header in '" + SONAR_SCANNER_HTTP_EXTRA_HEADERS + "': \"" + field + "\". Expected format: \"Name: Value\"");
353+
}
354+
var name = field.substring(0, colonIdx).trim();
355+
if (name.isEmpty()) {
356+
throw new IllegalArgumentException(
357+
"Invalid HTTP header in '" + SONAR_SCANNER_HTTP_EXTRA_HEADERS + "': \"" + field + "\". Header name cannot be blank");
358+
}
359+
if (name.equalsIgnoreCase("User-Agent")) {
360+
LOG.warn("The 'User-Agent' header cannot be overridden via '{}'. Ignoring.", SONAR_SCANNER_HTTP_EXTRA_HEADERS);
361+
return;
362+
}
363+
var value = field.substring(colonIdx + 1).trim();
364+
headers.put(name, value);
365+
}
303366
}

lib/src/main/java/org/sonarsource/scanner/lib/internal/http/ScannerHttpClient.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,11 @@ private HttpRequest prepareRequest(String url, @Nullable String acceptHeader, bo
224224
requestBuilder.header("Accept", acceptHeader);
225225
}
226226

227-
if (authentication) {
227+
// Extra headers are sent on every request (authenticated or not), to support corporate
228+
// proxies or SSO systems that require a specific header on all outbound traffic.
229+
httpConfig.getExtraHeaders().forEach(requestBuilder::header);
230+
231+
if (authentication && !httpConfig.hasCustomAuthorization()) {
228232
if (httpConfig.getToken() != null) {
229233
requestBuilder.header("Authorization", "Bearer " + httpConfig.getToken());
230234
} else if (httpConfig.getLogin() != null) {
@@ -238,7 +242,7 @@ private HttpRequest prepareRequest(String url, @Nullable String acceptHeader, bo
238242
// Proxy-Authorization from the application request to CONNECT tunnel requests for HTTPS
239243
// targets, so sending it upfront avoids a round-trip 407 challenge and works reliably
240244
// across JDK versions.
241-
if (httpConfig.getProxyUser() != null) {
245+
if (!httpConfig.hasCustomProxyAuthorization() && httpConfig.getProxyUser() != null) {
242246
String proxyCredentials = httpConfig.getProxyUser() + ":" + (httpConfig.getProxyPassword() != null ? httpConfig.getProxyPassword() : "");
243247
String encodedProxyCredentials = Base64.getEncoder().encodeToString(proxyCredentials.getBytes(StandardCharsets.UTF_8));
244248
requestBuilder.header("Proxy-Authorization", "Basic " + encodedProxyCredentials);

lib/src/test/java/org/sonarsource/scanner/lib/internal/http/HttpConfigTest.java

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@
2828
import java.util.HashMap;
2929
import java.util.Map;
3030
import org.junit.jupiter.api.BeforeEach;
31+
import org.junit.jupiter.api.Nested;
3132
import org.junit.jupiter.api.Test;
3233
import org.junit.jupiter.api.extension.RegisterExtension;
3334
import org.junit.jupiter.api.io.TempDir;
3435
import org.slf4j.event.Level;
36+
import org.sonarsource.scanner.lib.ScannerProperties;
3537
import org.sonarsource.scanner.lib.internal.util.System2;
3638
import testutils.LogTester;
3739

@@ -125,6 +127,96 @@ void should_skip_ssl_config_from_jvm_if_property_set() {
125127
assertThat(underTest.getSslConfig().getKeyStore()).isNull();
126128
}
127129

130+
@Nested
131+
class ExtraHeaders {
132+
133+
@Test
134+
void extraHeaders_are_empty_by_default() {
135+
var underTest = new HttpConfig(bootstrapProperties, sonarUserHome, system);
136+
137+
assertThat(underTest.getExtraHeaders()).isEmpty();
138+
}
139+
140+
@Test
141+
void extraHeaders_single_header() {
142+
bootstrapProperties.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "X-Auth: mytoken");
143+
144+
var underTest = new HttpConfig(bootstrapProperties, sonarUserHome, system);
145+
146+
assertThat(underTest.getExtraHeaders()).containsOnly(Map.entry("X-Auth", "mytoken"));
147+
}
148+
149+
@Test
150+
void extraHeaders_multiple_headers() {
151+
bootstrapProperties.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "X-Auth: mytoken,X-Tenant-Id: company");
152+
153+
var underTest = new HttpConfig(bootstrapProperties, sonarUserHome, system);
154+
155+
assertThat(underTest.getExtraHeaders())
156+
.containsOnly(Map.entry("X-Auth", "mytoken"), Map.entry("X-Tenant-Id", "company"));
157+
}
158+
159+
@Test
160+
void extraHeaders_trims_whitespace() {
161+
bootstrapProperties.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, " X-Auth : mytoken , X-Tenant-Id : company ");
162+
163+
var underTest = new HttpConfig(bootstrapProperties, sonarUserHome, system);
164+
165+
assertThat(underTest.getExtraHeaders())
166+
.containsOnly(Map.entry("X-Auth", "mytoken"), Map.entry("X-Tenant-Id", "company"));
167+
}
168+
169+
@Test
170+
void extraHeaders_handles_comma_in_quoted_value() {
171+
bootstrapProperties.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "X-Auth: mytoken,\"X-Link: <url1>, <url2>\"");
172+
173+
var underTest = new HttpConfig(bootstrapProperties, sonarUserHome, system);
174+
175+
assertThat(underTest.getExtraHeaders())
176+
.containsOnly(Map.entry("X-Auth", "mytoken"), Map.entry("X-Link", "<url1>, <url2>"));
177+
}
178+
179+
@Test
180+
void extraHeaders_warns_and_skips_user_agent() {
181+
bootstrapProperties.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "User-Agent: custom-agent");
182+
183+
var underTest = new HttpConfig(bootstrapProperties, sonarUserHome, system);
184+
185+
assertThat(underTest.getExtraHeaders()).isEmpty();
186+
assertThat(logTester.logs(Level.WARN))
187+
.contains("The 'User-Agent' header cannot be overridden via 'sonar.scanner.httpExtraHeaders'. Ignoring.");
188+
}
189+
190+
@Test
191+
void extraHeaders_throws_on_malformed_entry() {
192+
bootstrapProperties.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "no-colon-here");
193+
194+
assertThatThrownBy(() -> new HttpConfig(bootstrapProperties, sonarUserHome, system))
195+
.isInstanceOf(IllegalArgumentException.class)
196+
.hasMessageContaining("sonar.scanner.httpExtraHeaders")
197+
.hasMessageContaining("no-colon-here");
198+
}
199+
200+
@Test
201+
void extraHeaders_ignores_trailing_comma() {
202+
bootstrapProperties.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "X-Auth: mytoken,");
203+
204+
var underTest = new HttpConfig(bootstrapProperties, sonarUserHome, system);
205+
206+
assertThat(underTest.getExtraHeaders()).containsOnly(Map.entry("X-Auth", "mytoken"));
207+
}
208+
209+
@Test
210+
void extraHeaders_throws_on_blank_name() {
211+
bootstrapProperties.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, ": value");
212+
213+
assertThatThrownBy(() -> new HttpConfig(bootstrapProperties, sonarUserHome, system))
214+
.isInstanceOf(IllegalArgumentException.class)
215+
.hasMessageContaining("sonar.scanner.httpExtraHeaders")
216+
.hasMessageContaining("Header name cannot be blank");
217+
}
218+
}
219+
128220
@Test
129221
void should_set_ssl_config_from_jvm_system_properties(@TempDir Path tempDir) throws IOException {
130222
var jvmTruststore = tempDir.resolve("jvmTrust.p12");

lib/src/test/java/org/sonarsource/scanner/lib/internal/http/ScannerHttpClientTest.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,74 @@ void downloadFromExternalUrl_shouldNotPassAuth(@TempDir Path tmpFolder) throws E
198198
.withoutHeader("Authorization"));
199199
}
200200

201+
@Test
202+
void should_send_extra_headers_on_authenticated_requests() {
203+
Map<String, String> props = new HashMap<>();
204+
props.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "X-Auth: mytoken,X-Tenant-Id: company");
205+
ScannerHttpClient connection = create(sonarqube.baseUrl(), props);
206+
207+
answer(HELLO_WORLD);
208+
connection.callWebApi("/batch/index.txt");
209+
210+
sonarqube.verify(getRequestedFor(anyUrl())
211+
.withHeader("X-Auth", equalTo("mytoken"))
212+
.withHeader("X-Tenant-Id", equalTo("company")));
213+
}
214+
215+
@Test
216+
void should_also_send_extra_headers_on_external_url(@TempDir Path tmpFolder) {
217+
var toFile = tmpFolder.resolve("index.txt");
218+
Map<String, String> props = new HashMap<>();
219+
props.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "X-Corp-Token: secret");
220+
ScannerHttpClient underTest = create(sonarqube.baseUrl(), props);
221+
222+
answer(HELLO_WORLD);
223+
underTest.downloadFromExternalUrl(sonarqube.baseUrl() + "/batch/index.txt", toFile);
224+
225+
sonarqube.verify(getRequestedFor(anyUrl())
226+
.withHeader("X-Corp-Token", equalTo("secret")));
227+
}
228+
229+
@Test
230+
void should_use_extra_authorization_header_instead_of_token() {
231+
Map<String, String> props = new HashMap<>();
232+
props.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "Authorization: Bearer custom-scheme-token");
233+
ScannerHttpClient connection = create(sonarqube.baseUrl(), props);
234+
235+
answer(HELLO_WORLD);
236+
connection.callWebApi("/batch/index.txt");
237+
238+
sonarqube.verify(getRequestedFor(anyUrl())
239+
.withHeader("Authorization", equalTo("Bearer custom-scheme-token")));
240+
}
241+
242+
@Test
243+
void should_not_duplicate_authorization_when_both_token_and_extra_auth_header_set() {
244+
Map<String, String> props = new HashMap<>();
245+
props.put("sonar.token", "some_token");
246+
props.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "Authorization: Bearer custom-scheme-token");
247+
ScannerHttpClient connection = create(sonarqube.baseUrl(), props);
248+
249+
answer(HELLO_WORLD);
250+
connection.callWebApi("/batch/index.txt");
251+
252+
sonarqube.verify(getRequestedFor(anyUrl())
253+
.withHeader("Authorization", equalTo("Bearer custom-scheme-token")));
254+
}
255+
256+
@Test
257+
void should_not_override_user_agent_via_extra_headers() {
258+
Map<String, String> props = new HashMap<>();
259+
props.put(ScannerProperties.SONAR_SCANNER_HTTP_EXTRA_HEADERS, "User-Agent: custom-agent");
260+
ScannerHttpClient connection = create(sonarqube.baseUrl(), props);
261+
262+
answer(HELLO_WORLD);
263+
connection.callWebApi("/batch/index.txt");
264+
265+
sonarqube.verify(getRequestedFor(anyUrl())
266+
.withHeader("User-Agent", equalTo("user/agent")));
267+
}
268+
201269
@ParameterizedTest
202270
@ValueSource(ints = {301, 302, 303, 307, 308})
203271
void should_follow_redirects_and_preserve_authentication(int code) {

pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@
113113
<artifactId>commons-codec</artifactId>
114114
<version>1.21.0</version>
115115
</dependency>
116+
<dependency>
117+
<groupId>de.siegmar</groupId>
118+
<artifactId>fastcsv</artifactId>
119+
<version>3.7.0</version>
120+
</dependency>
116121
</dependencies>
117122
</dependencyManagement>
118123

0 commit comments

Comments
 (0)