|
| 1 | +/* |
| 2 | + * SonarScanner Java Library - ITs |
| 3 | + * Copyright (C) 2011-2025 SonarSource Sàrl |
| 4 | + * mailto:info AT sonarsource DOT com |
| 5 | + * |
| 6 | + * This program is free software; you can redistribute it and/or |
| 7 | + * modify it under the terms of the GNU Lesser General Public |
| 8 | + * License as published by the Free Software Foundation; either |
| 9 | + * version 3 of the License, or (at your option) any later version. |
| 10 | + * |
| 11 | + * This program is distributed in the hope that it will be useful, |
| 12 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| 14 | + * Lesser General Public License for more details. |
| 15 | + * |
| 16 | + * You should have received a copy of the GNU Lesser General Public License |
| 17 | + * along with this program; if not, write to the Free Software Foundation, |
| 18 | + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
| 19 | + */ |
| 20 | +package com.sonar.scanner.lib.it; |
| 21 | + |
| 22 | +import com.sonar.orchestrator.util.NetworkUtils; |
| 23 | +import java.io.IOException; |
| 24 | +import java.net.InetAddress; |
| 25 | +import java.nio.charset.StandardCharsets; |
| 26 | +import java.util.Base64; |
| 27 | +import java.util.Collection; |
| 28 | +import java.util.concurrent.ConcurrentLinkedDeque; |
| 29 | +import javax.servlet.ServletException; |
| 30 | +import javax.servlet.ServletRequest; |
| 31 | +import javax.servlet.ServletResponse; |
| 32 | +import javax.servlet.http.HttpServletRequest; |
| 33 | +import javax.servlet.http.HttpServletResponse; |
| 34 | +import org.eclipse.jetty.client.api.Request; |
| 35 | +import org.eclipse.jetty.http.HttpHeader; |
| 36 | +import org.eclipse.jetty.proxy.ConnectHandler; |
| 37 | +import org.eclipse.jetty.proxy.ProxyServlet; |
| 38 | +import org.eclipse.jetty.security.ConstraintMapping; |
| 39 | +import org.eclipse.jetty.security.ConstraintSecurityHandler; |
| 40 | +import org.eclipse.jetty.security.HashLoginService; |
| 41 | +import org.eclipse.jetty.security.SecurityHandler; |
| 42 | +import org.eclipse.jetty.security.ServerAuthException; |
| 43 | +import org.eclipse.jetty.security.UserAuthentication; |
| 44 | +import org.eclipse.jetty.security.UserStore; |
| 45 | +import org.eclipse.jetty.security.authentication.DeferredAuthentication; |
| 46 | +import org.eclipse.jetty.security.authentication.LoginAuthenticator; |
| 47 | +import org.eclipse.jetty.server.Authentication; |
| 48 | +import org.eclipse.jetty.server.Authentication.User; |
| 49 | +import org.eclipse.jetty.server.Handler; |
| 50 | +import org.eclipse.jetty.server.HttpConfiguration; |
| 51 | +import org.eclipse.jetty.server.HttpConnectionFactory; |
| 52 | +import org.eclipse.jetty.server.Server; |
| 53 | +import org.eclipse.jetty.server.ServerConnector; |
| 54 | +import org.eclipse.jetty.server.UserIdentity; |
| 55 | +import org.eclipse.jetty.server.handler.DefaultHandler; |
| 56 | +import org.eclipse.jetty.server.handler.HandlerCollection; |
| 57 | +import org.eclipse.jetty.servlet.ServletContextHandler; |
| 58 | +import org.eclipse.jetty.servlet.ServletHandler; |
| 59 | +import org.eclipse.jetty.util.security.Constraint; |
| 60 | +import org.eclipse.jetty.util.security.Credential; |
| 61 | +import org.eclipse.jetty.util.thread.QueuedThreadPool; |
| 62 | + |
| 63 | +class ProxyServer { |
| 64 | + |
| 65 | + // Static deques — needed so Jetty can instantiate TrackingProxyServlet via reflection |
| 66 | + // (no-arg constructor, so can't inject instance references). Safe because only one |
| 67 | + // proxy runs at a time and stop() clears them. |
| 68 | + private static final ConcurrentLinkedDeque<String> requestsSeenByProxy = new ConcurrentLinkedDeque<>(); |
| 69 | + private static final ConcurrentLinkedDeque<String> connectRequestsSeenByProxy = new ConcurrentLinkedDeque<>(); |
| 70 | + |
| 71 | + private final Server server; |
| 72 | + private final int port; |
| 73 | + |
| 74 | + private ProxyServer(Server server, int port) { |
| 75 | + this.server = server; |
| 76 | + this.port = port; |
| 77 | + } |
| 78 | + |
| 79 | + /** Starts an unauthenticated proxy. */ |
| 80 | + static ProxyServer start() throws Exception { |
| 81 | + return start(false, null, null); |
| 82 | + } |
| 83 | + |
| 84 | + /** Starts a proxy requiring Basic auth (Proxy-Authorization) on all requests and CONNECT tunnels. */ |
| 85 | + static ProxyServer start(String user, String password) throws Exception { |
| 86 | + return start(true, user, password); |
| 87 | + } |
| 88 | + |
| 89 | + private static ProxyServer start(boolean withProxyAuth, String user, String password) throws Exception { |
| 90 | + int port = NetworkUtils.getNextAvailablePort(InetAddress.getLocalHost()); |
| 91 | + |
| 92 | + QueuedThreadPool threadPool = new QueuedThreadPool(); |
| 93 | + threadPool.setMaxThreads(500); |
| 94 | + |
| 95 | + Server server = new Server(threadPool); |
| 96 | + |
| 97 | + HttpConfiguration httpConfig = new HttpConfiguration(); |
| 98 | + httpConfig.setSecureScheme("https"); |
| 99 | + httpConfig.setSendServerVersion(true); |
| 100 | + httpConfig.setSendDateHeader(false); |
| 101 | + |
| 102 | + TrackingConnectHandler connectHandler = new TrackingConnectHandler(withProxyAuth, user, password); |
| 103 | + connectHandler.setHandler(proxyHandler(withProxyAuth, user, password)); |
| 104 | + |
| 105 | + HandlerCollection handlers = new HandlerCollection(); |
| 106 | + handlers.setHandlers(new Handler[] {connectHandler, new DefaultHandler()}); |
| 107 | + server.setHandler(handlers); |
| 108 | + |
| 109 | + ServerConnector http = new ServerConnector(server, new HttpConnectionFactory(httpConfig)); |
| 110 | + http.setPort(port); |
| 111 | + server.addConnector(http); |
| 112 | + |
| 113 | + server.start(); |
| 114 | + return new ProxyServer(server, port); |
| 115 | + } |
| 116 | + |
| 117 | + int getPort() { |
| 118 | + return port; |
| 119 | + } |
| 120 | + |
| 121 | + Collection<String> getRequestsSeenByProxy() { |
| 122 | + return requestsSeenByProxy; |
| 123 | + } |
| 124 | + |
| 125 | + Collection<String> getConnectRequestsSeenByProxy() { |
| 126 | + return connectRequestsSeenByProxy; |
| 127 | + } |
| 128 | + |
| 129 | + void stop() throws Exception { |
| 130 | + server.stop(); |
| 131 | + requestsSeenByProxy.clear(); |
| 132 | + connectRequestsSeenByProxy.clear(); |
| 133 | + } |
| 134 | + |
| 135 | + private static ServletContextHandler proxyHandler(boolean withProxyAuth, String user, String password) { |
| 136 | + ServletContextHandler contextHandler = new ServletContextHandler(); |
| 137 | + if (withProxyAuth) { |
| 138 | + contextHandler.setSecurityHandler(basicAuth(user, password, "Private!")); |
| 139 | + } |
| 140 | + contextHandler.setServletHandler(newServletHandler()); |
| 141 | + return contextHandler; |
| 142 | + } |
| 143 | + |
| 144 | + private static SecurityHandler basicAuth(String username, String password, String realm) { |
| 145 | + HashLoginService l = new HashLoginService(realm); |
| 146 | + |
| 147 | + UserStore userStore = new UserStore(); |
| 148 | + userStore.addUser(username, Credential.getCredential(password), new String[] {"user"}); |
| 149 | + l.setUserStore(userStore); |
| 150 | + |
| 151 | + Constraint constraint = new Constraint(); |
| 152 | + constraint.setName(Constraint.__BASIC_AUTH); |
| 153 | + constraint.setRoles(new String[] {"user"}); |
| 154 | + constraint.setAuthenticate(true); |
| 155 | + |
| 156 | + ConstraintMapping cm = new ConstraintMapping(); |
| 157 | + cm.setConstraint(constraint); |
| 158 | + cm.setPathSpec("/*"); |
| 159 | + |
| 160 | + ConstraintSecurityHandler csh = new ConstraintSecurityHandler(); |
| 161 | + csh.setAuthenticator(new ProxyAuthenticator()); |
| 162 | + csh.setRealmName("myrealm"); |
| 163 | + csh.addConstraintMapping(cm); |
| 164 | + csh.setLoginService(l); |
| 165 | + |
| 166 | + return csh; |
| 167 | + } |
| 168 | + |
| 169 | + private static ServletHandler newServletHandler() { |
| 170 | + ServletHandler handler = new ServletHandler(); |
| 171 | + handler.addServletWithMapping(TrackingProxyServlet.class, "/*"); |
| 172 | + return handler; |
| 173 | + } |
| 174 | + |
| 175 | + /** |
| 176 | + * ConnectHandler subclass that: |
| 177 | + * <ul> |
| 178 | + * <li>Optionally requires {@code Proxy-Authorization} on CONNECT requests</li> |
| 179 | + * <li>Records the host:port of every successfully-authenticated CONNECT</li> |
| 180 | + * </ul> |
| 181 | + * <p> |
| 182 | + * When authentication is required and credentials are missing, the handler sends a well-formed |
| 183 | + * {@code 407} response. This allows the JDK {@link java.net.Authenticator} to read the challenge, |
| 184 | + * supply credentials, and retry the CONNECT on a new connection. |
| 185 | + */ |
| 186 | + private static class TrackingConnectHandler extends ConnectHandler { |
| 187 | + |
| 188 | + private final boolean requireAuth; |
| 189 | + private final String user; |
| 190 | + private final String password; |
| 191 | + |
| 192 | + TrackingConnectHandler(boolean requireAuth, String user, String password) { |
| 193 | + this.requireAuth = requireAuth; |
| 194 | + this.user = user; |
| 195 | + this.password = password; |
| 196 | + } |
| 197 | + |
| 198 | + @Override |
| 199 | + protected void handleConnect(org.eclipse.jetty.server.Request baseRequest, HttpServletRequest request, |
| 200 | + HttpServletResponse response, String serverAddress) { |
| 201 | + if (requireAuth && !hasValidCredentials(request)) { |
| 202 | + response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); |
| 203 | + response.setHeader("Proxy-Authenticate", "Basic realm=\"proxy\""); |
| 204 | + response.setContentLength(0); |
| 205 | + baseRequest.setHandled(true); |
| 206 | + return; |
| 207 | + } |
| 208 | + connectRequestsSeenByProxy.add(serverAddress); |
| 209 | + super.handleConnect(baseRequest, request, response, serverAddress); |
| 210 | + } |
| 211 | + |
| 212 | + private boolean hasValidCredentials(HttpServletRequest request) { |
| 213 | + String credentials = request.getHeader("Proxy-Authorization"); |
| 214 | + if (credentials != null && credentials.startsWith("Basic ")) { |
| 215 | + String decoded = new String(Base64.getDecoder().decode(credentials.substring(6)), StandardCharsets.ISO_8859_1); |
| 216 | + int colon = decoded.indexOf(':'); |
| 217 | + if (colon > 0) { |
| 218 | + String reqUser = decoded.substring(0, colon); |
| 219 | + String reqPass = decoded.substring(colon + 1); |
| 220 | + return user.equals(reqUser) && password.equals(reqPass); |
| 221 | + } |
| 222 | + } |
| 223 | + return false; |
| 224 | + } |
| 225 | + } |
| 226 | + |
| 227 | + // Must stay public static for Jetty servlet reflection |
| 228 | + public static class TrackingProxyServlet extends ProxyServlet { |
| 229 | + |
| 230 | + @Override |
| 231 | + protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { |
| 232 | + requestsSeenByProxy.add(request.getRequestURI()); |
| 233 | + super.service(request, response); |
| 234 | + } |
| 235 | + |
| 236 | + @Override |
| 237 | + protected void sendProxyRequest(HttpServletRequest clientRequest, HttpServletResponse proxyResponse, Request proxyRequest) { |
| 238 | + super.sendProxyRequest(clientRequest, proxyResponse, proxyRequest); |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + /** |
| 243 | + * Authenticator for HTTP forward proxy that reads {@code Proxy-Authorization} instead of the |
| 244 | + * standard {@code Authorization} header. |
| 245 | + * Inspired from Jetty's {@code BasicAuthenticator} but adapted for proxy auth. |
| 246 | + */ |
| 247 | + private static class ProxyAuthenticator extends LoginAuthenticator { |
| 248 | + |
| 249 | + @Override |
| 250 | + public String getAuthMethod() { |
| 251 | + return Constraint.__BASIC_AUTH; |
| 252 | + } |
| 253 | + |
| 254 | + @Override |
| 255 | + public Authentication validateRequest(ServletRequest req, ServletResponse res, boolean mandatory) throws ServerAuthException { |
| 256 | + HttpServletRequest request = (HttpServletRequest) req; |
| 257 | + HttpServletResponse response = (HttpServletResponse) res; |
| 258 | + String credentials = request.getHeader(HttpHeader.PROXY_AUTHORIZATION.asString()); |
| 259 | + |
| 260 | + try { |
| 261 | + if (!mandatory) { |
| 262 | + return new DeferredAuthentication(this); |
| 263 | + } |
| 264 | + |
| 265 | + if (credentials != null) { |
| 266 | + int space = credentials.indexOf(' '); |
| 267 | + if (space > 0) { |
| 268 | + String method = credentials.substring(0, space); |
| 269 | + if ("basic".equalsIgnoreCase(method)) { |
| 270 | + credentials = credentials.substring(space + 1); |
| 271 | + credentials = new String(Base64.getDecoder().decode(credentials), StandardCharsets.ISO_8859_1); |
| 272 | + int i = credentials.indexOf(':'); |
| 273 | + if (i > 0) { |
| 274 | + String username = credentials.substring(0, i); |
| 275 | + String password = credentials.substring(i + 1); |
| 276 | + UserIdentity user = login(username, password, request); |
| 277 | + if (user != null) { |
| 278 | + return new UserAuthentication(getAuthMethod(), user); |
| 279 | + } |
| 280 | + } |
| 281 | + } |
| 282 | + } |
| 283 | + } |
| 284 | + |
| 285 | + if (DeferredAuthentication.isDeferred(response)) { |
| 286 | + return Authentication.UNAUTHENTICATED; |
| 287 | + } |
| 288 | + |
| 289 | + response.setHeader(HttpHeader.PROXY_AUTHENTICATE.asString(), "basic realm=\"" + _loginService.getName() + '"'); |
| 290 | + response.sendError(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED); |
| 291 | + return Authentication.SEND_CONTINUE; |
| 292 | + } catch (IOException e) { |
| 293 | + throw new ServerAuthException(e); |
| 294 | + } |
| 295 | + } |
| 296 | + |
| 297 | + @Override |
| 298 | + public boolean secureResponse(ServletRequest req, ServletResponse res, boolean mandatory, User validatedUser) throws ServerAuthException { |
| 299 | + return true; |
| 300 | + } |
| 301 | + } |
| 302 | +} |
0 commit comments