diff --git a/pom.xml b/pom.xml index 3548c9e8a..6abf4e60b 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ org.littleshoot littleproxy jar - 1.1.17-VGS-SNAPSHOT + 1.1.2-ntlm-20200117 LittleProxy LittleProxy is a high performance HTTP proxy written in Java and using the Netty networking framework. @@ -14,7 +14,7 @@ UTF-8 UTF-8 github - 4.1.28.Final + 4.1.54.Fina 1.7.24 1.8 8 @@ -225,7 +225,7 @@ com.google.guava guava - 23.0 + 30.0-jre @@ -273,7 +273,7 @@ org.eclipse.jetty jetty-server - 8.1.17.v20150415 + 9.4.34.v20201102 test @@ -329,6 +329,18 @@ netty-all + + jcifs + jcifs + 1.3.17 + + + servlet-api + javax.servlet + + + + com.barchart.udt barchart-udt-bundle @@ -547,6 +559,19 @@ + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + org.apache.maven.plugins maven-javadoc-plugin diff --git a/src/main/java/org/littleshoot/proxy/ChainedProxy.java b/src/main/java/org/littleshoot/proxy/ChainedProxy.java index 3292c4253..911082b6e 100644 --- a/src/main/java/org/littleshoot/proxy/ChainedProxy.java +++ b/src/main/java/org/littleshoot/proxy/ChainedProxy.java @@ -4,6 +4,8 @@ import java.net.InetSocketAddress; +import org.littleshoot.proxy.ntlm.NtlmHandler; + /** *

* Encapsulates information needed to connect to a chained proxy. @@ -49,6 +51,17 @@ public interface ChainedProxy extends SslEngineSource { */ boolean requiresEncryption(); + /** + * Tell LittleProxy whether or not to do NTLM authentication to the chained + * proxy. Same instance should be returned to maintain state during NLTM + * handshake. + * + * null indicates that we won't do NTLM handshake. + * + * @return NtlmHandler + */ + NtlmHandler getNtlmHandler(); + /** * Filters requests on their way to the chained proxy. * diff --git a/src/main/java/org/littleshoot/proxy/ChainedProxyAdapter.java b/src/main/java/org/littleshoot/proxy/ChainedProxyAdapter.java index 61f42881f..2905de919 100644 --- a/src/main/java/org/littleshoot/proxy/ChainedProxyAdapter.java +++ b/src/main/java/org/littleshoot/proxy/ChainedProxyAdapter.java @@ -6,6 +6,8 @@ import javax.net.ssl.SSLEngine; +import org.littleshoot.proxy.ntlm.NtlmHandler; + /** * Convenience base class for implementations of {@link ChainedProxy}. */ @@ -40,7 +42,12 @@ public boolean requiresEncryption() { public SSLEngine newSslEngine() { return null; } - + + @Override + public NtlmHandler getNtlmHandler() { + return null; + } + @Override public void filterRequest(HttpObject httpObject) { } diff --git a/src/main/java/org/littleshoot/proxy/FlowContext.java b/src/main/java/org/littleshoot/proxy/FlowContext.java index 15bf9ac89..fd013ee81 100644 --- a/src/main/java/org/littleshoot/proxy/FlowContext.java +++ b/src/main/java/org/littleshoot/proxy/FlowContext.java @@ -7,6 +7,8 @@ import org.littleshoot.proxy.impl.ClientToProxyConnection; +import io.netty.channel.ChannelHandlerContext; + /** *

* Encapsulates contextual information for flow information that's being @@ -16,15 +18,26 @@ public class FlowContext { private final InetSocketAddress clientAddress; private final SSLSession clientSslSession; + private final ChannelHandlerContext ctx; public FlowContext(ClientToProxyConnection clientConnection) { super(); + this.ctx = clientConnection.getContext(); this.clientAddress = clientConnection.getClientAddress(); SSLEngine sslEngine = clientConnection.getSslEngine(); this.clientSslSession = sslEngine != null ? sslEngine.getSession() : null; } + /** + * The client to proxy channel context. + * + * @return + */ + public ChannelHandlerContext getClientToProxyContext() { + return ctx; + } + /** * The address of the client. * diff --git a/src/main/java/org/littleshoot/proxy/FullFlowContext.java b/src/main/java/org/littleshoot/proxy/FullFlowContext.java index 43a4d9fa8..b5251318c 100644 --- a/src/main/java/org/littleshoot/proxy/FullFlowContext.java +++ b/src/main/java/org/littleshoot/proxy/FullFlowContext.java @@ -3,6 +3,8 @@ import org.littleshoot.proxy.impl.ClientToProxyConnection; import org.littleshoot.proxy.impl.ProxyToServerConnection; +import io.netty.channel.ChannelHandlerContext; + /** * Extension of {@link FlowContext} that provides additional information (which * we know after actually processing the request from the client). @@ -10,14 +12,25 @@ public class FullFlowContext extends FlowContext { private final String serverHostAndPort; private final ChainedProxy chainedProxy; + private final ChannelHandlerContext ctx; public FullFlowContext(ClientToProxyConnection clientConnection, ProxyToServerConnection serverConnection) { super(clientConnection); + this.ctx = serverConnection.getContext(); this.serverHostAndPort = serverConnection.getServerHostAndPort(); this.chainedProxy = serverConnection.getChainedProxy(); } + /** + * The proxy to server channel context. + * + * @return + */ + public ChannelHandlerContext getProxyToServerContext() { + return ctx; + } + /** * The host and port for the server (i.e. the ultimate endpoint). * diff --git a/src/main/java/org/littleshoot/proxy/impl/ClientToProxyConnection.java b/src/main/java/org/littleshoot/proxy/impl/ClientToProxyConnection.java index f7ff1ebd9..09e10f1c1 100644 --- a/src/main/java/org/littleshoot/proxy/impl/ClientToProxyConnection.java +++ b/src/main/java/org/littleshoot/proxy/impl/ClientToProxyConnection.java @@ -860,7 +860,7 @@ protected void exceptionCaught(Throwable cause) { private void initChannelPipeline(ChannelPipeline pipeline) { LOG.debug("Configuring ChannelPipeline"); - if (proxyServer.getRequestTracer() != null) { + if (!proxyServer.getActivityTrackers().isEmpty() && proxyServer.getRequestTracer() != null) { pipeline.addLast("requestTracerHandler", new RequestTracerHandler(this)); } @@ -870,8 +870,14 @@ private void initChannelPipeline(ChannelPipeline pipeline) { EventExecutorGroup globalStateWrapperEvenLoop = new GlobalStateWrapperEvenLoop(this); - pipeline.addLast(globalStateWrapperEvenLoop, "bytesReadMonitor", bytesReadMonitor); - pipeline.addLast(globalStateWrapperEvenLoop, "bytesWrittenMonitor", bytesWrittenMonitor); + if(!proxyServer.getActivityTrackers().isEmpty()){ + LOG.info("Activity Trackers are available: {}. Enabled monitoring.", proxyServer.getActivityTrackers().size()); + for (final ActivityTracker activityTracker : proxyServer.getActivityTrackers()) { + LOG.debug("Activity Tracker: {}", activityTracker.getClass()); + } + pipeline.addLast(globalStateWrapperEvenLoop, "bytesReadMonitor", bytesReadMonitor); + pipeline.addLast(globalStateWrapperEvenLoop, "bytesWrittenMonitor", bytesWrittenMonitor); + } pipeline.addLast("proxyProtocolReader", new HttpProxyProtocolRequestDecoder()); @@ -890,8 +896,10 @@ private void initChannelPipeline(ChannelPipeline pipeline) { aggregateContentForFiltering(pipeline, numberOfBytesToBuffer); } - pipeline.addLast(globalStateWrapperEvenLoop, "requestReadMonitor", requestReadMonitor); - pipeline.addLast(globalStateWrapperEvenLoop, "responseWrittenMonitor", responseWrittenMonitor); + if(!proxyServer.getActivityTrackers().isEmpty()){ + pipeline.addLast(globalStateWrapperEvenLoop, "requestReadMonitor", requestReadMonitor); + pipeline.addLast(globalStateWrapperEvenLoop, "responseWrittenMonitor", responseWrittenMonitor); + } pipeline.addLast( "idle", @@ -1349,7 +1357,8 @@ private boolean respondWithShortCircuitResponse(HttpResponse httpResponse) { // if the response is not a Bad Gateway or Gateway Timeout, modify the headers "as if" the short-circuit response were proxied int statusCode = httpResponse.getStatus().code(); - if (statusCode != HttpResponseStatus.BAD_GATEWAY.code() && statusCode != HttpResponseStatus.GATEWAY_TIMEOUT.code()) { + if (statusCode != HttpResponseStatus.BAD_GATEWAY.code() && statusCode != HttpResponseStatus.GATEWAY_TIMEOUT.code() + && statusCode != HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED.code()) { modifyResponseHeadersToReflectProxying(httpResponse); } diff --git a/src/main/java/org/littleshoot/proxy/impl/ConnectionFlow.java b/src/main/java/org/littleshoot/proxy/impl/ConnectionFlow.java index 5a044bb58..5f4533ac8 100644 --- a/src/main/java/org/littleshoot/proxy/impl/ConnectionFlow.java +++ b/src/main/java/org/littleshoot/proxy/impl/ConnectionFlow.java @@ -54,6 +54,18 @@ ConnectionFlow then(ConnectionFlowStep step) { return this; } + /** + * Indicates whether the given message is relevant to the current step. + * Invoker should check this before calling read(). + * + * @param msg + * the message read from the underlying connection + * @return + */ + boolean isRelevant(Object msg) { + return currentStep.isRelevant(msg); + } + /** * While we're in the process of connecting, any messages read by the * {@link ProxyToServerConnection} are passed to this method, which passes diff --git a/src/main/java/org/littleshoot/proxy/impl/ConnectionFlowStep.java b/src/main/java/org/littleshoot/proxy/impl/ConnectionFlowStep.java index c60910d59..997df9317 100644 --- a/src/main/java/org/littleshoot/proxy/impl/ConnectionFlowStep.java +++ b/src/main/java/org/littleshoot/proxy/impl/ConnectionFlowStep.java @@ -83,6 +83,17 @@ void onSuccess(ConnectionFlow flow) { flow.advance(); } + /** + * Indicates whether the given message is relevant to this step. + * + * @param msg + * the message read from the underlying connection + * @return + */ + boolean isRelevant(Object msg) { + return true; + } + /** *

* Any messages that are read from the underlying connection while we're at diff --git a/src/main/java/org/littleshoot/proxy/impl/ConnectionState.java b/src/main/java/org/littleshoot/proxy/impl/ConnectionState.java index 371fcd586..92c5be784 100644 --- a/src/main/java/org/littleshoot/proxy/impl/ConnectionState.java +++ b/src/main/java/org/littleshoot/proxy/impl/ConnectionState.java @@ -11,6 +11,12 @@ enum ConnectionState { */ HANDSHAKING(true), + /** + * In the process of sending NTLM negotiate (Type-1) + * and waiting to receive NTLM challenge (Type-2). + */ + NTLM_HANDSHAKING(true), + /** * In the process of negotiating an HTTP CONNECT from the client. */ diff --git a/src/main/java/org/littleshoot/proxy/impl/ProxyConnection.java b/src/main/java/org/littleshoot/proxy/impl/ProxyConnection.java index eed893c22..cf69153c7 100644 --- a/src/main/java/org/littleshoot/proxy/impl/ProxyConnection.java +++ b/src/main/java/org/littleshoot/proxy/impl/ProxyConnection.java @@ -887,21 +887,20 @@ public void write(ChannelHandlerContext ctx, } /** - * Invoked immediately before an HttpRequest is written. - */ + * Invoked immediately before an HttpRequest is written. + */ protected abstract void requestWriting(HttpRequest httpRequest); /** - * Invoked immediately after an HttpRequest has been sent. - */ + * Invoked immediately after an HttpRequest has been sent. + */ protected abstract void requestWritten(HttpRequest httpRequest); /** - * Invoked immediately after an HttpContent has been sent. - */ + * Invoked immediately after an HttpContent has been sent. + */ protected abstract void contentWritten(HttpContent httpContent); } - /** * Utility handler for monitoring responses written on this connection. */ @@ -926,4 +925,7 @@ public void write(ChannelHandlerContext ctx, protected abstract void responseWritten(HttpResponse httpResponse); } + public ChannelHandlerContext getContext() { + return ctx; + } } diff --git a/src/main/java/org/littleshoot/proxy/impl/ProxyToServerConnection.java b/src/main/java/org/littleshoot/proxy/impl/ProxyToServerConnection.java index 3cb87c38c..f5ca864f7 100644 --- a/src/main/java/org/littleshoot/proxy/impl/ProxyToServerConnection.java +++ b/src/main/java/org/littleshoot/proxy/impl/ProxyToServerConnection.java @@ -1,5 +1,9 @@ package org.littleshoot.proxy.impl; +import static io.netty.handler.codec.http.HttpHeaders.Names.PROXY_AUTHENTICATE; +import static io.netty.handler.codec.http.HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED; +import static org.littleshoot.proxy.impl.ConnectionState.*; + import com.google.common.net.HostAndPort; import io.netty.bootstrap.Bootstrap; import io.netty.bootstrap.ChannelFactory; @@ -15,20 +19,7 @@ import io.netty.channel.ChannelPipeline; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.channel.udt.nio.NioUdtProvider; -import io.netty.handler.codec.http.FullHttpResponse; -import io.netty.handler.codec.http.HttpContent; -import io.netty.handler.codec.http.HttpHeaders; -import io.netty.handler.codec.http.HttpMessage; -import io.netty.handler.codec.http.HttpObject; -import io.netty.handler.codec.http.HttpObjectAggregator; -import io.netty.handler.codec.http.HttpRequest; -import io.netty.handler.codec.http.HttpRequestEncoder; -import io.netty.handler.codec.http.HttpResponse; -import io.netty.handler.codec.http.HttpResponseDecoder; -import io.netty.handler.codec.http.HttpResponseEncoder; -import io.netty.handler.codec.http.HttpResponseStatus; -import io.netty.handler.codec.http.HttpVersion; -import io.netty.handler.codec.http.LastHttpContent; +import io.netty.handler.codec.http.*; import io.netty.handler.timeout.IdleStateHandler; import io.netty.handler.traffic.GlobalTrafficShapingHandler; import io.netty.util.AttributeKey; @@ -47,6 +38,7 @@ import org.littleshoot.proxy.ExceptionHandler; import org.littleshoot.proxy.TransportProtocol; import org.littleshoot.proxy.UnknownTransportProtocolException; +import org.littleshoot.proxy.ntlm.NtlmException; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLProtocolException; @@ -54,17 +46,11 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.net.UnknownHostException; +import java.util.List; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.RejectedExecutionException; -import static org.littleshoot.proxy.impl.ConnectionState.AWAITING_CHUNK; -import static org.littleshoot.proxy.impl.ConnectionState.AWAITING_CONNECT_OK; -import static org.littleshoot.proxy.impl.ConnectionState.AWAITING_INITIAL; -import static org.littleshoot.proxy.impl.ConnectionState.CONNECTING; -import static org.littleshoot.proxy.impl.ConnectionState.DISCONNECTED; -import static org.littleshoot.proxy.impl.ConnectionState.HANDSHAKING; - /** *

* Represents a connection from our proxy to a server on the web. @@ -214,6 +200,13 @@ private ProxyToServerConnection( @Override protected void read(Object msg) { + enableNtlmIfRequired(msg); + + if (isConnecting() && !connectionFlow.isRelevant(msg)) { + LOG.debug("Aborting the current step in the connection flow."); + connectionFlow.advance(); + } + if (isConnecting()) { LOG.debug( "In the middle of connecting, forwarding message to connection flow: {}", @@ -224,6 +217,29 @@ protected void read(Object msg) { } } + private void enableNtlmIfRequired(Object msg) { + if (msg instanceof HttpResponse && isNtlmRequired((HttpResponse) msg)) { + LOG.debug("NTLM authentication required. Failing the current connection."); + connectionFlow.fail(new NtlmException()); + } + } + + private static boolean isNtlmRequired(HttpResponse httpResponse) { + if (httpResponse.getStatus().equals(PROXY_AUTHENTICATION_REQUIRED)) { + List schemes = httpResponse.headers().getAll(PROXY_AUTHENTICATE); + return schemes.contains("NTLM"); + } + return false; + } + + private static boolean isNtlmChallenge(HttpResponse httpResponse) { + if (httpResponse.getStatus().equals(PROXY_AUTHENTICATION_REQUIRED)) { + String scheme = httpResponse.headers().get(PROXY_AUTHENTICATE); + return scheme.startsWith("NTLM "); + } + return false; + } + @Override protected ConnectionState readHTTPInitial(ChannelHandlerContext ctx, Object httpResponseObj) { HttpResponse httpResponse = (HttpResponse) httpResponseObj; @@ -599,12 +615,15 @@ private void initializeConnectionFlow() { if (chainedProxy != null && chainedProxy.requiresEncryption()) { InetSocketAddress proxyAddress = chainedProxy.getChainedProxyAddress(); - - SSLEngine engine = proxyAddress == null || proxyAddress.isUnresolved() ? chainedProxy.newSslEngine() : - chainedProxy.newSslEngine(proxyAddress.getHostName(), proxyAddress.getPort()); + SSLEngine engine = chainedProxy.newSslEngine(proxyAddress.getHostName(), proxyAddress.getPort()); + engine = engine == null ? chainedProxy.newSslEngine() : engine; connectionFlow.then(serverConnection.EncryptChannel(engine)); } + if (chainedProxy != null && chainedProxy.getNtlmHandler() != null) { + connectionFlow.then(serverConnection.NtlmWithChainedProxy); + } + if (ProxyUtils.isCONNECT(initialRequest)) { // If we're chaining, forward the CONNECT request if (hasUpstreamChainedProxy()) { @@ -696,9 +715,22 @@ protected void initChannel(Channel ch) throws Exception { */ private ConnectionFlowStep HTTPCONNECTWithChainedProxy = new ConnectionFlowStep( this, AWAITING_CONNECT_OK) { + + private boolean shouldWrite; + + boolean shouldExecuteOnEventLoop() { + shouldWrite = ntlmAuthenticate(initialRequest); + return shouldWrite; + } + protected Future execute() { + if(!shouldWrite) { + return channel.newSucceededFuture(); + } + LOG.debug("Handling CONNECT request through Chained Proxy"); chainedProxy.filterRequest(initialRequest); + currentHttpRequest = initialRequest; MitmManager mitmManager = proxyServer.getMitmManager(clientConnection.channel); boolean isMitmEnabled = mitmManager != null; /* @@ -758,6 +790,71 @@ void read(ConnectionFlow flow, Object msg) { } }; + private ConnectionFlowStep NtlmWithChainedProxy = new ConnectionFlowStep(this, NTLM_HANDSHAKING) { + + private boolean challenged; + + boolean isRelevant(Object msg) { + if (msg instanceof HttpResponse) { + HttpResponse httpResponse = (HttpResponse) msg; + challenged = isNtlmChallenge(httpResponse); + } + return challenged; + }; + + protected Future execute() { + HttpRequest request = copyAsFull(initialRequest); + chainedProxy.getNtlmHandler().negotiate(request); + currentHttpRequest = request; + LOG.debug("NTLM negotiate message: {}", request); + return writeToChannel(request); + } + + void onSuccess(ConnectionFlow flow) { + // Do nothing, wait for NTLM Type-2 response + } + + void read(ConnectionFlow flow, Object msg) { + // Set NTLM Type-2 response + if (msg instanceof HttpResponse) { + HttpResponse httpResponse = (HttpResponse) msg; + try { + chainedProxy.getNtlmHandler().challenge(httpResponse); + } catch (Exception e) { + LOG.warn("Failed to handle NTLM challenge message", e); + flow.fail(); + } + } + + if (msg instanceof LastHttpContent) { + LOG.debug("Completed reading NTLM challenge message"); + flow.advance(); + return; + } + + if (msg instanceof HttpContent) { + LOG.debug("Ignored NTLM challenge message content"); + } + } + }; + + private static HttpRequest copyAsFull(HttpRequest origin) { + HttpRequest request = null; + if (origin instanceof FullHttpRequest) { + request = origin; + ReferenceCountUtil.retain(request); + } else { + request = new DefaultFullHttpRequest(origin.getProtocolVersion(), origin.getMethod(), origin.getUri()); + if(origin.headers() != null){ + request.headers().add(origin.headers()); + } + HttpHeaders.setHost(request, HttpHeaders.getHost(origin)); + } + request.headers().set("Proxy-Connection", "Keep-Alive"); + request.headers().set("Connection", "Keep-Alive"); + return request; + } + /** *

* Encrypts the client channel based on our server {@link SSLSession}. @@ -838,9 +935,7 @@ protected boolean connectionFailed(Throwable cause) } else { LOG.info("Connection to upstream server failed", cause); } - - // attempt to connect using a chained proxy, if available - chainedProxy = availableChainedProxies.poll(); + chainedProxy = getProxyToConnect(cause); if (chainedProxy != null) { LOG.info("Retrying connecting using the next available chained proxy"); @@ -871,6 +966,15 @@ private void resetConnectionForRetry() throws UnknownHostException { this.setupConnectionParameters(); } + private ChainedProxy getProxyToConnect(Throwable cause) { + // Retry connection to the same proxy + if (cause instanceof NtlmException && chainedProxy.getNtlmHandler() != null) { + LOG.debug("Retrying connection to {}", chainedProxy.getChainedProxyAddress()); + return chainedProxy; + } + return availableChainedProxies.poll(); + } + /** * Set up our connection parameters based on server address and chained * proxies. @@ -929,7 +1033,7 @@ private void setupConnectionParameters() throws UnknownHostException { * {@link HttpObjectAggregator} in the {@link ChannelPipeline}. * * @param pipeline - * @param httpRequest + * @param channel */ private void initChannelPipeline(ChannelPipeline pipeline, Channel channel) { @@ -943,8 +1047,14 @@ private void initChannelPipeline(ChannelPipeline pipeline, Channel channel) { final EventExecutorGroup globalStateWrapperEvenLoop = new GlobalStateWrapperEvenLoop(clientConnection, channel.eventLoop()); - pipeline.addLast(globalStateWrapperEvenLoop, "bytesReadMonitor", bytesReadMonitor); - pipeline.addLast(globalStateWrapperEvenLoop, "bytesWrittenMonitor", bytesWrittenMonitor); + if(!proxyServer.getActivityTrackers().isEmpty()) { + LOG.info("Activity Trackers are available: {}. Enabled monitoring.", proxyServer.getActivityTrackers().size()); + for (final ActivityTracker activityTracker : proxyServer.getActivityTrackers()) { + LOG.debug("Activity Tracker: {}", activityTracker.getClass()); + } + pipeline.addLast(globalStateWrapperEvenLoop, "bytesReadMonitor", bytesReadMonitor); + pipeline.addLast(globalStateWrapperEvenLoop, "bytesWrittenMonitor", bytesWrittenMonitor); + } pipeline.addLast("encoder", new HttpRequestEncoder()); pipeline.addLast("decoder", new HeadAwareHttpResponseDecoder( @@ -958,9 +1068,10 @@ private void initChannelPipeline(ChannelPipeline pipeline, Channel channel) { if (numberOfBytesToBuffer > 0) { aggregateContentForFiltering(pipeline, numberOfBytesToBuffer); } - - pipeline.addLast(globalStateWrapperEvenLoop, "responseReadMonitor", responseReadMonitor); - pipeline.addLast(globalStateWrapperEvenLoop, "requestWrittenMonitor", requestWrittenMonitor); + if(!proxyServer.getActivityTrackers().isEmpty()) { + pipeline.addLast(globalStateWrapperEvenLoop, "requestWrittenMonitor", requestWrittenMonitor); + pipeline.addLast(globalStateWrapperEvenLoop, "responseReadMonitor", responseReadMonitor); + } // Set idle timeout pipeline.addLast( @@ -1001,14 +1112,35 @@ void connectionSucceeded(boolean shouldForwardInitialRequest) { shouldForwardInitialRequest); if (shouldForwardInitialRequest) { - LOG.debug("Writing initial request: {}", initialRequest); - write(initialRequest); + boolean shouldWrite = ntlmAuthenticate(initialRequest); + if(shouldWrite) { + LOG.debug("Writing initial request: {}", initialRequest); + write(initialRequest); + } } else { LOG.debug("Dropping initial request: {}", initialRequest); } } + /** + * Set authentication details to NTLM proxy if configured. + * + * @param httpRequest + * The request that may need authentication + * @return true if the request needs to be written + */ + private boolean ntlmAuthenticate(HttpRequest httpRequest) { + if (chainedProxy != null && chainedProxy.getNtlmHandler() != null) { + boolean challenged = chainedProxy.getNtlmHandler().isChallenged(); + if (challenged) { + chainedProxy.getNtlmHandler().authenticate(httpRequest); + } + return challenged; + } + return true; + } + /** * Build an {@link InetSocketAddress} for the given hostAndPort. * @@ -1104,5 +1236,4 @@ protected void contentWritten(HttpContent httpContent) { } } }; - } diff --git a/src/main/java/org/littleshoot/proxy/ntlm/JcifsNtlmProvider.java b/src/main/java/org/littleshoot/proxy/ntlm/JcifsNtlmProvider.java new file mode 100644 index 000000000..0c18b06a7 --- /dev/null +++ b/src/main/java/org/littleshoot/proxy/ntlm/JcifsNtlmProvider.java @@ -0,0 +1,73 @@ +package org.littleshoot.proxy.ntlm; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jcifs.ntlmssp.NtlmMessage; +import jcifs.ntlmssp.Type1Message; +import jcifs.ntlmssp.Type2Message; +import jcifs.ntlmssp.Type3Message; + +import static com.google.common.base.Preconditions.checkNotNull; +import static jcifs.ntlmssp.Type3Message.getDefaultFlags; +import static org.apache.commons.lang3.StringUtils.EMPTY; + +/** + * Reference implementation of {@link NtlmProvider} + */ +public class JcifsNtlmProvider implements NtlmProvider { + + private static final Logger LOG = LoggerFactory.getLogger(JcifsNtlmProvider.class); + + private final int flags; + + private final String user; + + private final String password; + + private final String domain; + + private final String workstation; + + private Type2Message type2; + + public JcifsNtlmProvider(int flags, String user, String password, String domain, String workstation) { + this.flags = flags > 0 ? flags : getDefaultFlags(); + this.user = checkNotNull(user); + this.password = checkNotNull(password); + this.domain = checkNotNull(domain); + this.workstation = checkNotNull(workstation); + } + + public JcifsNtlmProvider(String user, String password, String domain) { + this(0, user, password, domain, EMPTY); + } + + @Override + public byte[] getType1() { + NtlmMessage type1 = new Type1Message(flags, domain, workstation); + LOG.debug("NTLM {}", type1); + return type1.toByteArray(); + } + + @Override + public boolean setType2(byte[] material) { + try { + type2 = new Type2Message(material); + LOG.debug("NTLM {}", type2); + return true; + } catch (IOException e) { + LOG.warn("Unable to parse NTLM Type2 message", e); + return false; + } + } + + @Override + public byte[] getType3() { + NtlmMessage type3 = new Type3Message(type2, password, domain, user, workstation, type2.getFlags()); + LOG.debug("NTLM {}", type3); + return type3.toByteArray(); + } +} diff --git a/src/main/java/org/littleshoot/proxy/ntlm/NtlmException.java b/src/main/java/org/littleshoot/proxy/ntlm/NtlmException.java new file mode 100644 index 000000000..c8cd7fe7f --- /dev/null +++ b/src/main/java/org/littleshoot/proxy/ntlm/NtlmException.java @@ -0,0 +1,6 @@ +package org.littleshoot.proxy.ntlm; + +@SuppressWarnings("serial") +public class NtlmException extends Exception { + +} diff --git a/src/main/java/org/littleshoot/proxy/ntlm/NtlmHandler.java b/src/main/java/org/littleshoot/proxy/ntlm/NtlmHandler.java new file mode 100644 index 000000000..66523a1b5 --- /dev/null +++ b/src/main/java/org/littleshoot/proxy/ntlm/NtlmHandler.java @@ -0,0 +1,23 @@ +package org.littleshoot.proxy.ntlm; + +import org.littleshoot.proxy.ChainedProxy; + +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; + +/** + * This serves as an extension to {@link ChainedProxy} which requires + * NTLM authentication. Implementation should set negotiate (Type-11) + * and challenge (Type-3) Proxy-Authorization headers to the request. + */ +public interface NtlmHandler { + + void negotiate(HttpRequest httpRequest); + + void challenge(HttpResponse httpResponse); + + void authenticate(HttpRequest httpRequest); + + boolean isChallenged(); + +} diff --git a/src/main/java/org/littleshoot/proxy/ntlm/NtlmHandlerImpl.java b/src/main/java/org/littleshoot/proxy/ntlm/NtlmHandlerImpl.java new file mode 100644 index 000000000..6ea4b4452 --- /dev/null +++ b/src/main/java/org/littleshoot/proxy/ntlm/NtlmHandlerImpl.java @@ -0,0 +1,85 @@ +package org.littleshoot.proxy.ntlm; + +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.io.BaseEncoding.base64; +import static io.netty.handler.codec.http.HttpHeaders.Names.PROXY_AUTHENTICATE; +import static io.netty.handler.codec.http.HttpHeaders.Names.PROXY_AUTHORIZATION; +import static io.netty.handler.codec.http.HttpHeaders.isKeepAlive; +import static org.apache.commons.lang3.StringUtils.substringAfter; + +/** + * This class is responsible for writing and reading NTLM related request and + * response headers respectively. It delegates the creating of NTLM messages to + * a provider. + */ +public class NtlmHandlerImpl implements NtlmHandler { + + private final NtlmProvider provider; + + private boolean challenged; + + public NtlmHandlerImpl(NtlmProvider provider) { + this.provider = provider; + } + + @Override + public void negotiate(HttpRequest httpRequest) { + assertChallengeNotRead(); + writeNegotiation(httpRequest); + } + + @Override + public void challenge(HttpResponse httpResponse) { + assertPersistentConnection(httpResponse); + assertChallengeNotRead(); + readChallenge(httpResponse); + assertChallengeRead(); + } + + @Override + public void authenticate(HttpRequest httpRequest) { + assertChallengeRead(); + writeAuthentication(httpRequest); + } + + @Override + public boolean isChallenged() { + return challenged; + } + + private void writeNegotiation(HttpRequest httpRequest) { + byte[] type1 = provider.getType1(); + setAuthHeader(httpRequest, type1); + } + + private void readChallenge(HttpResponse httpResponse) { + String proxyAuth = httpResponse.headers().get(PROXY_AUTHENTICATE); + String authChallenge = substringAfter(proxyAuth, "NTLM "); + challenged = provider.setType2(base64().decode(authChallenge)); + } + + private void writeAuthentication(HttpRequest httpRequest) { + byte[] type3 = provider.getType3(); + setAuthHeader(httpRequest, type3); + } + + private static void assertPersistentConnection(HttpResponse httpResponse) { + checkState(isKeepAlive(httpResponse), "Connection closed during NTLM handshake"); + } + + private void assertChallengeNotRead() { + checkState(!challenged, "NTLM challenge already read"); + } + + private void assertChallengeRead() { + checkState(challenged, "Failed to read NTLM challenge"); + } + + private static void setAuthHeader(HttpRequest httpRequest, byte[] msg) { + httpRequest.headers().set(PROXY_AUTHORIZATION, "NTLM " + base64().encode(msg)); + } + +} diff --git a/src/main/java/org/littleshoot/proxy/ntlm/NtlmProvider.java b/src/main/java/org/littleshoot/proxy/ntlm/NtlmProvider.java new file mode 100644 index 000000000..4fb01850a --- /dev/null +++ b/src/main/java/org/littleshoot/proxy/ntlm/NtlmProvider.java @@ -0,0 +1,39 @@ +package org.littleshoot.proxy.ntlm; + +/** + * Implementation may use some library like jCIFS to deal with various NTLM + * messages. + * + * Reference + */ +public interface NtlmProvider { + + /** + * This primarily contains a list of features supported by the client and + * requested of the server. + * + * @return Type 1 (negotiation) + */ + byte[] getType1(); + + /** + * This contains a list of features supported and agreed upon by the server. + * Most importantly, however, it contains a challenge generated by the + * server. + * + * @param material + * Type 2 (challenge) + * @return Whether the client was able to parse the challenge + */ + boolean setType2(byte[] material); + + /** + * This contains several pieces of information about the client, including + * the domain and username of the client user. It also contains one or more + * responses to the Type 2 challenge. + * + * @return Type 3 (authentication) + */ + byte[] getType3(); + +} diff --git a/src/test/java/org/littleshoot/proxy/HttpFilterTest.java b/src/test/java/org/littleshoot/proxy/HttpFilterTest.java index ca786aa3c..59cf768b8 100644 --- a/src/test/java/org/littleshoot/proxy/HttpFilterTest.java +++ b/src/test/java/org/littleshoot/proxy/HttpFilterTest.java @@ -20,6 +20,8 @@ import org.mockserver.matchers.Times; import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSession; + import java.io.IOException; import java.net.InetSocketAddress; import java.net.Socket; @@ -94,6 +96,7 @@ private void setUpHttpProxyServer(HttpFiltersSource filtersSource) { this.proxyServer = DefaultHttpProxyServer.bootstrap() .withPort(0) .withFiltersSource(filtersSource) + .plusActivityTracker(getDummyActivityTracker()) .start(); final InetSocketAddress isa = new InetSocketAddress("127.0.0.1", proxyServer.getListenAddress().getPort()); @@ -695,6 +698,7 @@ public HttpFilters filterRequest(HttpRequest originalRequest) { .withPort(0) .withFiltersSource(filtersSource) .withIdleConnectionTimeout(3) + .plusActivityTracker(getDummyActivityTracker()) .start(); org.apache.http.HttpResponse httpResponse = HttpClientUtil.performHttpGet("http://localhost:" + mockServerPort + "/servertimeout", proxyServer); @@ -725,7 +729,7 @@ public HttpFilters filterRequest(HttpRequest originalRequest) { assertFalse("Expected filter method to not be called", filter.isProxyToServerConnectionFailedInvoked()); assertFalse("Expected filter method to not be called", filter.isProxyToServerConnectionSSLHandshakeStartedInvoked()); } - + @Test public void testRequestSentInvokedAfterLastHttpContentSent() throws Exception { final AtomicBoolean lastHttpContentProcessed = new AtomicBoolean(false); @@ -992,4 +996,63 @@ public void proxyToServerConnectionSSLHandshakeStarted() { proxyToServerConnectionSSLHandshakeStarted.set(true); } } + + private ActivityTracker getDummyActivityTracker() { + return new ActivityTracker() { + @Override + public void clientConnected(final InetSocketAddress clientAddress) { + + } + + @Override + public void clientSSLHandshakeSucceeded(final InetSocketAddress clientAddress, final SSLSession sslSession) { + + } + + @Override + public void clientDisconnected(final InetSocketAddress clientAddress, final SSLSession sslSession) { + + } + + @Override + public void bytesReceivedFromClient(final FlowContext flowContext, final int numberOfBytes) { + + } + + @Override + public void requestReceivedFromClient(final FlowContext flowContext, final HttpRequest httpRequest) { + + } + + @Override + public void bytesSentToServer(final FullFlowContext flowContext, final int numberOfBytes) { + + } + + @Override + public void requestSentToServer(final FullFlowContext flowContext, final HttpRequest httpRequest) { + + } + + @Override + public void bytesReceivedFromServer(final FullFlowContext flowContext, final int numberOfBytes) { + + } + + @Override + public void responseReceivedFromServer(final FullFlowContext flowContext, final HttpResponse httpResponse) { + + } + + @Override + public void bytesSentToClient(final FlowContext flowContext, final int numberOfBytes) { + + } + + @Override + public void responseSentToClient(final FlowContext flowContext, final HttpResponse httpResponse) { + + } + }; + } }