Skip to content

Commit 85e6f27

Browse files
committed
SCANJLIB-306 Restore authentication support for proxy + SSL
1 parent d81e195 commit 85e6f27

File tree

5 files changed

+212
-19
lines changed

5 files changed

+212
-19
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ jobs:
8383
- name: Run ITs
8484
run: |
8585
cd its
86-
mvn -B -e verify -Prun-its -Dsonar.runtimeVersion=${{ matrix.SQ_VERSION }} -DjavaVersion=${{ env.JAVA_VERSION }}
86+
mvn -B -e verify -Prun-its -Dsonar.runtimeVersion=${{ matrix.SQ_VERSION }}
8787
8888
- name: Upload server logs
8989
if: failure()

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

Lines changed: 173 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,19 @@
2626
import com.sonar.scanner.lib.it.tools.SimpleScanner;
2727
import java.io.IOException;
2828
import java.net.InetAddress;
29+
import java.nio.charset.StandardCharsets;
2930
import java.nio.file.Path;
3031
import java.nio.file.Paths;
32+
import java.util.Base64;
3133
import java.util.HashMap;
3234
import java.util.Map;
3335
import java.util.concurrent.ConcurrentLinkedDeque;
3436
import javax.servlet.ServletException;
3537
import javax.servlet.http.HttpServletRequest;
3638
import javax.servlet.http.HttpServletResponse;
3739
import org.eclipse.jetty.client.api.Request;
40+
import org.eclipse.jetty.http.HttpVersion;
41+
import org.eclipse.jetty.proxy.ConnectHandler;
3842
import org.eclipse.jetty.proxy.ProxyServlet;
3943
import org.eclipse.jetty.security.ConstraintMapping;
4044
import org.eclipse.jetty.security.ConstraintSecurityHandler;
@@ -46,12 +50,15 @@
4650
import org.eclipse.jetty.server.HttpConnectionFactory;
4751
import org.eclipse.jetty.server.Server;
4852
import org.eclipse.jetty.server.ServerConnector;
53+
import org.eclipse.jetty.server.SslConnectionFactory;
4954
import org.eclipse.jetty.server.handler.DefaultHandler;
5055
import org.eclipse.jetty.server.handler.HandlerCollection;
5156
import org.eclipse.jetty.servlet.ServletContextHandler;
5257
import org.eclipse.jetty.servlet.ServletHandler;
58+
import org.eclipse.jetty.servlet.ServletHolder;
5359
import org.eclipse.jetty.util.security.Constraint;
5460
import org.eclipse.jetty.util.security.Credential;
61+
import org.eclipse.jetty.util.ssl.SslContextFactory;
5562
import org.eclipse.jetty.util.thread.QueuedThreadPool;
5663
import org.junit.After;
5764
import org.junit.Before;
@@ -64,45 +71,62 @@ public class ProxyTest {
6471

6572
private static final String PROXY_USER = "scott";
6673
private static final String PROXY_PASSWORD = "tiger";
74+
75+
// SSL resources reused from SSLTest
76+
private static final String SERVER_KEYSTORE = "/SSLTest/server.p12";
77+
private static final String SERVER_KEYSTORE_PASSWORD = "pwdServerP12";
78+
private static final String KEYSTORE_CLIENT_WITH_CA = "/SSLTest/client-with-ca-keytool.p12";
79+
private static final String KEYSTORE_CLIENT_WITH_CA_PASSWORD = "pwdClientCAP12";
80+
6781
private static Server server;
6882
private static int httpProxyPort;
83+
// HTTPS reverse-proxy target, used for the HTTPS CONNECT tests
84+
private static Server httpsTargetServer;
85+
private static int httpsTargetPort;
6986

7087
@ClassRule
7188
public static final OrchestratorRule ORCHESTRATOR = ScannerJavaLibraryTestSuite.ORCHESTRATOR;
7289

73-
private static ConcurrentLinkedDeque<String> seenByProxy = new ConcurrentLinkedDeque<>();
90+
private static final ConcurrentLinkedDeque<String> seenByProxy = new ConcurrentLinkedDeque<>();
91+
private static final ConcurrentLinkedDeque<String> seenConnectByProxy = new ConcurrentLinkedDeque<>();
7492

7593
@Before
7694
public void deleteData() {
7795
ScannerJavaLibraryTestSuite.resetData(ORCHESTRATOR);
7896
seenByProxy.clear();
97+
seenConnectByProxy.clear();
7998
}
8099

81100
@After
82101
public void stopProxy() throws Exception {
83102
if (server != null && server.isStarted()) {
84103
server.stop();
85104
}
105+
if (httpsTargetServer != null && httpsTargetServer.isStarted()) {
106+
httpsTargetServer.stop();
107+
}
86108
}
87109

88110
private static void startProxy(boolean needProxyAuth) throws Exception {
89111
httpProxyPort = NetworkUtils.getNextAvailablePort(InetAddress.getLocalHost());
90112

91-
// Setup Threadpool
92113
QueuedThreadPool threadPool = new QueuedThreadPool();
93114
threadPool.setMaxThreads(500);
94115

95116
server = new Server(threadPool);
96117

97-
// HTTP Configuration
98118
HttpConfiguration httpConfig = new HttpConfiguration();
99119
httpConfig.setSecureScheme("https");
100120
httpConfig.setSendServerVersion(true);
101121
httpConfig.setSendDateHeader(false);
102122

103-
// Handler Structure
123+
// Wrap the ProxyServlet handler with a ConnectHandler so HTTPS CONNECT
124+
// tunnels are also handled (and authenticated) by the same proxy.
125+
TrackingConnectHandler connectHandler = new TrackingConnectHandler(needProxyAuth);
126+
connectHandler.setHandler(proxyHandler(needProxyAuth));
127+
104128
HandlerCollection handlers = new HandlerCollection();
105-
handlers.setHandlers(new Handler[] {proxyHandler(needProxyAuth), new DefaultHandler()});
129+
handlers.setHandlers(new Handler[] {connectHandler, new DefaultHandler()});
106130
server.setHandler(handlers);
107131

108132
ServerConnector http = new ServerConnector(server, new HttpConnectionFactory(httpConfig));
@@ -112,6 +136,55 @@ private static void startProxy(boolean needProxyAuth) throws Exception {
112136
server.start();
113137
}
114138

139+
/**
140+
* Starts a simple HTTPS reverse-proxy that forwards all traffic to the Orchestrator SonarQube
141+
* instance. Used as the HTTPS target in proxy-CONNECT tests.
142+
*/
143+
private static void startHttpsTargetServer() throws Exception {
144+
httpsTargetPort = NetworkUtils.getNextAvailablePort(InetAddress.getLocalHost());
145+
146+
QueuedThreadPool threadPool = new QueuedThreadPool();
147+
threadPool.setMaxThreads(500);
148+
149+
httpsTargetServer = new Server(threadPool);
150+
151+
HttpConfiguration httpConfig = new HttpConfiguration();
152+
httpConfig.setSecureScheme("https");
153+
httpConfig.setSecurePort(httpsTargetPort);
154+
httpConfig.setSendServerVersion(true);
155+
httpConfig.setSendDateHeader(false);
156+
157+
Path serverKeyStore = Paths.get(ProxyTest.class.getResource(SERVER_KEYSTORE).toURI()).toAbsolutePath();
158+
assertThat(serverKeyStore).exists();
159+
160+
ServerConnector sslConnector = buildServerConnector(serverKeyStore, httpConfig);
161+
httpsTargetServer.addConnector(sslConnector);
162+
163+
// Transparently forward all requests to the Orchestrator instance
164+
ServletContextHandler context = new ServletContextHandler();
165+
ServletHandler servletHandler = new ServletHandler();
166+
ServletHolder holder = servletHandler.addServletWithMapping(ProxyServlet.Transparent.class, "/*");
167+
holder.setInitParameter("proxyTo", ORCHESTRATOR.getServer().getUrl());
168+
context.setServletHandler(servletHandler);
169+
httpsTargetServer.setHandler(context);
170+
171+
httpsTargetServer.start();
172+
}
173+
174+
private static ServerConnector buildServerConnector(Path serverKeyStore, HttpConfiguration httpConfig) {
175+
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
176+
sslContextFactory.setKeyStorePath(serverKeyStore.toString());
177+
sslContextFactory.setKeyStorePassword(SERVER_KEYSTORE_PASSWORD);
178+
sslContextFactory.setKeyManagerPassword(SERVER_KEYSTORE_PASSWORD);
179+
180+
HttpConfiguration httpsConfig = new HttpConfiguration(httpConfig);
181+
ServerConnector sslConnector = new ServerConnector(httpsTargetServer,
182+
new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()),
183+
new HttpConnectionFactory(httpsConfig));
184+
sslConnector.setPort(httpsTargetPort);
185+
return sslConnector;
186+
}
187+
115188
private static ServletContextHandler proxyHandler(boolean needProxyAuth) {
116189
ServletContextHandler contextHandler = new ServletContextHandler();
117190
if (needProxyAuth) {
@@ -155,6 +228,55 @@ private static ServletHandler newServletHandler() {
155228
return handler;
156229
}
157230

231+
/**
232+
* ConnectHandler subclass that:
233+
* <ul>
234+
* <li>Optionally requires {@code Proxy-Authorization} on CONNECT requests</li>
235+
* <li>Records the host:port of every successfully-authenticated CONNECT</li>
236+
* </ul>
237+
* <p>
238+
* When authentication is required and credentials are missing, the handler sends a well-formed
239+
* {@code 407} response and lets Jetty close the connection naturally. This allows the JDK
240+
* {@link java.net.Authenticator} to read the challenge, supply credentials, and retry the CONNECT
241+
* on a new connection — exactly the flow that the {@code HttpClientFactory} fix enables.
242+
*/
243+
private static class TrackingConnectHandler extends ConnectHandler {
244+
245+
private final boolean requireAuth;
246+
247+
TrackingConnectHandler(boolean requireAuth) {
248+
this.requireAuth = requireAuth;
249+
}
250+
251+
@Override
252+
protected void handleConnect(org.eclipse.jetty.server.Request baseRequest, HttpServletRequest request,
253+
HttpServletResponse response, String serverAddress) {
254+
if (requireAuth && !hasValidCredentials(request)) {
255+
response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
256+
response.setHeader("Proxy-Authenticate", "Basic realm=\"proxy\"");
257+
response.setContentLength(0);
258+
baseRequest.setHandled(true);
259+
return;
260+
}
261+
seenConnectByProxy.add(serverAddress);
262+
super.handleConnect(baseRequest, request, response, serverAddress);
263+
}
264+
265+
private static boolean hasValidCredentials(HttpServletRequest request) {
266+
String credentials = request.getHeader("Proxy-Authorization");
267+
if (credentials != null && credentials.startsWith("Basic ")) {
268+
String decoded = new String(Base64.getDecoder().decode(credentials.substring(6)), StandardCharsets.ISO_8859_1);
269+
int colon = decoded.indexOf(':');
270+
if (colon > 0) {
271+
String user = decoded.substring(0, colon);
272+
String pass = decoded.substring(colon + 1);
273+
return PROXY_USER.equals(user) && PROXY_PASSWORD.equals(pass);
274+
}
275+
}
276+
return false;
277+
}
278+
}
279+
158280
public static class MyProxyServlet extends ProxyServlet {
159281

160282
@Override
@@ -202,6 +324,8 @@ public void simple_analysis_with_proxy_auth() throws Exception {
202324
SimpleScanner scanner = new SimpleScanner();
203325

204326
Map<String, String> params = new HashMap<>();
327+
// By default no request to localhost will use proxy
328+
params.put("http.nonProxyHosts", "");
205329
params.put("sonar.scanner.proxyHost", "localhost");
206330
params.put("sonar.scanner.proxyPort", "" + httpProxyPort);
207331

@@ -218,4 +342,48 @@ public void simple_analysis_with_proxy_auth() throws Exception {
218342
assertThat(buildResult.getLastStatus()).isZero();
219343
}
220344

345+
/**
346+
* Reproduces the regression reported for SonarScanner CLI 8.0 (java-library 4.0):
347+
* HTTPS proxy authentication was broken — the {@code Proxy-Authorization} header was
348+
* not sent on the CONNECT tunnel, so the proxy kept returning 407.
349+
* <p>
350+
* This test uses a local HTTP forward proxy that enforces authentication on CONNECT
351+
* requests, plus a local HTTPS reverse-proxy that forwards to the running SonarQube
352+
* instance. This mirrors the real-world topology: scanner → HTTP proxy (CONNECT) →
353+
* HTTPS SonarQube.
354+
*/
355+
@Test
356+
public void simple_analysis_with_https_proxy_auth() throws Exception {
357+
startProxy(true);
358+
startHttpsTargetServer();
359+
SimpleScanner scanner = new SimpleScanner();
360+
361+
Path clientTruststore = Paths.get(ProxyTest.class.getResource(KEYSTORE_CLIENT_WITH_CA).toURI()).toAbsolutePath();
362+
assertThat(clientTruststore).exists();
363+
364+
Map<String, String> params = new HashMap<>();
365+
// By default no request to localhost will use proxy
366+
params.put("http.nonProxyHosts", "");
367+
// JDK-8210814 without that, the JDK is not doing basic authentication
368+
params.put("jdk.http.auth.tunneling.disabledSchemes", "");
369+
params.put("sonar.scanner.proxyHost", "localhost");
370+
params.put("sonar.scanner.proxyPort", "" + httpProxyPort);
371+
// Trust the self-signed certificate used by the local HTTPS target
372+
params.put("sonar.scanner.truststorePath", clientTruststore.toString());
373+
params.put("sonar.scanner.truststorePassword", KEYSTORE_CLIENT_WITH_CA_PASSWORD);
374+
375+
// Without proxy credentials the CONNECT tunnel should be rejected (407)
376+
BuildResult buildResult = scanner.executeSimpleProject(project("js-sample"), "https://localhost:" + httpsTargetPort, params, Map.of());
377+
assertThat(buildResult.getLastStatus()).isNotZero();
378+
assertThat(buildResult.getLogs()).containsIgnoringCase("Failed to query server version");
379+
assertThat(seenConnectByProxy).isEmpty();
380+
381+
// With proxy credentials the CONNECT tunnel must succeed and the full analysis must pass
382+
params.put("sonar.scanner.proxyUser", PROXY_USER);
383+
params.put("sonar.scanner.proxyPassword", PROXY_PASSWORD);
384+
buildResult = scanner.executeSimpleProject(project("js-sample"), "https://localhost:" + httpsTargetPort, params, Map.of());
385+
assertThat(buildResult.getLastStatus()).isZero();
386+
assertThat(seenConnectByProxy).isNotEmpty();
387+
}
388+
221389
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ private static Duration loadDuration(Map<String, String> bootstrapProperties, St
144144

145145
@Nullable
146146
private static Proxy loadProxy(Map<String, String> bootstrapProperties) {
147-
// OkHttp detects 'http.proxyHost' java property already, so just focus on sonar-specific properties
148147
String proxyHost = defaultIfBlank(bootstrapProperties.get(SONAR_SCANNER_PROXY_HOST), null);
149148
if (proxyHost != null) {
150149
int proxyPort;

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

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public class ScannerHttpClient {
4646
private static final Logger LOG = LoggerFactory.getLogger(ScannerHttpClient.class);
4747
private static final String EXCEPTION_MESSAGE_MISSING_SLASH = "URL path must start with slash: %s";
4848

49-
private HttpClient sharedHttpClient;
49+
private HttpClient httpClient;
5050
private HttpConfig httpConfig;
5151

5252
public void init(HttpConfig httpConfig) {
@@ -55,7 +55,7 @@ public void init(HttpConfig httpConfig) {
5555

5656
void init(HttpConfig httpConfig, HttpClient httpClient) {
5757
this.httpConfig = httpConfig;
58-
this.sharedHttpClient = httpClient;
58+
this.httpClient = httpClient;
5959
}
6060

6161
public void downloadFromRestApi(String urlPath, Path toFile) {
@@ -146,21 +146,21 @@ private <G> G callUrlWithRedirects(String url, boolean authentication, @Nullable
146146
}
147147

148148
private <G> G callUrlWithRedirectsAndProxyAuth(String url, boolean authentication, @Nullable String acceptHeader, ResponseHandler<G> responseHandler,
149-
int redirectCount, boolean proxyAuthAttempted) {
149+
int redirectCount, boolean proxyAuthRetry) {
150150
if (redirectCount > 10) {
151151
throw new IllegalStateException("Too many redirects (>10) for URL: " + url);
152152
}
153153

154-
var request = prepareRequest(url, acceptHeader, authentication, proxyAuthAttempted);
154+
var request = prepareRequest(url, acceptHeader, authentication, proxyAuthRetry);
155155

156156
HttpResponse<InputStream> response = null;
157157
Instant start = Instant.now();
158158
try {
159159
LOG.debug("--> {} {}", request.method(), request.uri());
160-
response = sharedHttpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
160+
response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
161161

162-
if (response.statusCode() == 407 && !proxyAuthAttempted && httpConfig.getProxyUser() != null) {
163-
LOG.debug("Received 407 Proxy Authentication Required, retrying with Proxy-Authorization header");
162+
if (response.statusCode() == 407 && !proxyAuthRetry && httpConfig.getProxyUser() != null) {
163+
LOG.debug("Received 407 Proxy Authentication Required, retrying request");
164164
return callUrlWithRedirectsAndProxyAuth(url, authentication, acceptHeader, responseHandler, redirectCount, true);
165165
}
166166

@@ -172,7 +172,7 @@ private <G> G callUrlWithRedirectsAndProxyAuth(String url, boolean authenticatio
172172
URI originalUri = URI.create(url);
173173
redirectUrl = originalUri.getScheme() + "://" + originalUri.getAuthority() + redirectUrl;
174174
}
175-
return callUrlWithRedirectsAndProxyAuth(redirectUrl, authentication, acceptHeader, responseHandler, redirectCount + 1, proxyAuthAttempted);
175+
return callUrlWithRedirectsAndProxyAuth(redirectUrl, authentication, acceptHeader, responseHandler, redirectCount + 1, proxyAuthRetry);
176176
}
177177
}
178178

@@ -200,7 +200,9 @@ private <G> G callUrlWithRedirectsAndProxyAuth(String url, boolean authenticatio
200200
private static String tryReadBody(HttpResponse<InputStream> response) {
201201
String errorBody = null;
202202
try (InputStream body = response.body()) {
203-
errorBody = new String(body.readAllBytes(), StandardCharsets.UTF_8);
203+
if (body != null) {
204+
errorBody = new String(body.readAllBytes(), StandardCharsets.UTF_8);
205+
}
204206
} catch (IOException e) {
205207
// Ignore
206208
}
@@ -209,14 +211,14 @@ private static String tryReadBody(HttpResponse<InputStream> response) {
209211

210212
private static boolean isRedirect(int statusCode) {
211213
return statusCode == 301 || statusCode == 302 || statusCode == 303 ||
212-
statusCode == 307 || statusCode == 308;
214+
statusCode == 307 || statusCode == 308;
213215
}
214216

215217
private interface ResponseHandler<G> {
216218
G apply(HttpResponse<InputStream> response) throws IOException;
217219
}
218220

219-
private HttpRequest prepareRequest(String url, @Nullable String acceptHeader, boolean authentication, boolean addProxyAuth) {
221+
private HttpRequest prepareRequest(String url, @Nullable String acceptHeader, boolean authentication, boolean proxyAuthRetry) {
220222
var timeout = httpConfig.getResponseTimeout().isZero() ? httpConfig.getSocketTimeout() : httpConfig.getResponseTimeout();
221223

222224
var requestBuilder = HttpRequest.newBuilder()
@@ -239,7 +241,11 @@ private HttpRequest prepareRequest(String url, @Nullable String acceptHeader, bo
239241
}
240242
}
241243

242-
if (addProxyAuth && httpConfig.getProxyUser() != null) {
244+
// Preemptively send proxy credentials on every request. The JDK HttpClient forwards
245+
// Proxy-Authorization from the application request to CONNECT tunnel requests for HTTPS
246+
// targets, so sending it upfront avoids a round-trip 407 challenge and works reliably
247+
// across JDK versions.
248+
if (httpConfig.getProxyUser() != null) {
243249
String proxyCredentials = httpConfig.getProxyUser() + ":" + (httpConfig.getProxyPassword() != null ? httpConfig.getProxyPassword() : "");
244250
String encodedProxyCredentials = Base64.getEncoder().encodeToString(proxyCredentials.getBytes(StandardCharsets.UTF_8));
245251
requestBuilder.header("Proxy-Authorization", "Basic " + encodedProxyCredentials);

0 commit comments

Comments
 (0)