diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/HttpStatusLine.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/HttpStatusLine.java new file mode 100644 index 0000000..c8ac92b --- /dev/null +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/HttpStatusLine.java @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.proton.transport.proxy; + +import java.util.Locale; +import java.util.Objects; + +/** + * The first line in an HTTP 1.0/1.1 response. Consists of the HTTP protocol version, status code, and a reason phrase + * for the HTTP response. + * + * @see RFC 2616 + */ +public final class HttpStatusLine { + private final String httpVersion; + private final int statusCode; + private final String reason; + + /** + * Creates a new instance of {@link HttpStatusLine}. + * + * @param protocolVersion The HTTP protocol version. For example, 1.0, 1.1. + * @param statusCode A numeric status code for the HTTP response. + * @param reason Textual phrase representing the HTTP status code. + */ + private HttpStatusLine(String protocolVersion, int statusCode, String reason) { + this.httpVersion = Objects.requireNonNull(protocolVersion, "'httpVersion' cannot be null."); + this.statusCode = statusCode; + this.reason = Objects.requireNonNull(reason, "'reason' cannot be null."); + } + + /** + * Parses the provided {@code statusLine} into an HTTP status line. + * + * @param line Line to parse into an HTTP status line. + * @return A new instance of {@link HttpStatusLine} representing the given {@code statusLine}. + * @throws IllegalArgumentException if {@code line} is not the correct format of an HTTP status line. If it + * does not have a protocol version, status code, or reason component. Or, if the HTTP protocol version + * cannot be parsed. + */ + public static HttpStatusLine create(String line) { + final String[] components = line.split(" ", 3); + if (components.length != 3) { + throw new IllegalArgumentException(String.format(Locale.ROOT, + "HTTP status-line is invalid. Line: %s", line)); + } + + final String[] protocol = components[0].split("/", 2); + if (protocol.length != 2) { + throw new IllegalArgumentException(String.format(Locale.ROOT, + "Protocol is invalid, expected HTTP/{version}. Actual: %s", components[0])); + } + + int statusCode; + try { + statusCode = Integer.parseInt(components[1]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(String.format(Locale.US, + "HTTP Status code '%s' is not valid.", components[1]), e); + } + + return new HttpStatusLine(protocol[1], statusCode, components[2]); + } + + /** + * Gets the HTTP protocol version. + * + * @return The HTTP protocol version. + */ + public String getProtocolVersion() { + return this.httpVersion; + } + + /** + * Gets the HTTP status code. + * + * @return The HTTP status code. + */ + public int getStatusCode() { + return this.statusCode; + } + + /** + * Gets the textual representation for the HTTP status code. + * + * @return The textual representation for the HTTP status code. + */ + public String getReason() { + return this.reason; + } +} diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyHandler.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyHandler.java index f51f608..09a58b2 100644 --- a/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyHandler.java +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyHandler.java @@ -3,7 +3,6 @@ package com.microsoft.azure.proton.transport.proxy; -import java.nio.ByteBuffer; import java.util.Map; /** @@ -11,33 +10,6 @@ import java.util.Map; */ public interface ProxyHandler { - /** - * Represents a response from the proxy. - */ - class ProxyResponseResult { - private final Boolean isSuccess; - private final String error; - - /** - * Creates a new response. - * - * @param isSuccess {@code true} if it was successful; {@code false} otherwise. - * @param error The error from the proxy. Or {@code null} if there was none. - */ - public ProxyResponseResult(final Boolean isSuccess, final String error) { - this.isSuccess = isSuccess; - this.error = error; - } - - public Boolean getIsSuccess() { - return isSuccess; - } - - public String getError() { - return error; - } - } - /** * Creates a CONNECT request to the provided {@code hostName} and adds {@code additionalHeaders} to the request. * @@ -48,11 +20,11 @@ public interface ProxyHandler { String createProxyRequest(String hostName, Map additionalHeaders); /** - * Verifies that {@code buffer} contains a successful CONNECT response. + * Verifies that {@code httpResponse} contains a successful CONNECT response. + * + * @param httpResponse HTTP response to validate for a successful CONNECT response. + * @return {@code true} if the HTTP response is successful and correct, and {@code false} otherwise. * - * @param buffer Buffer containing the HTTP response. - * @return Indicates if CONNECT response contained a success. If not, contains an error indicating why the call was - * not successful. */ - ProxyResponseResult validateProxyResponse(ByteBuffer buffer); + boolean validateProxyResponse(ProxyResponse httpResponse); } diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyResponse.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyResponse.java new file mode 100644 index 0000000..57c9015 --- /dev/null +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/ProxyResponse.java @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.proton.transport.proxy; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; + +/** + * Represents an HTTP response from a proxy. + */ +public interface ProxyResponse { + /** + * Gets the headers for the HTTP response. + * + * @return The headers for the HTTP response. + */ + Map> getHeaders(); + + /** + * Gets the HTTP status line. + * + * @return The HTTP status line. + */ + HttpStatusLine getStatus(); + + /** + * Gets the HTTP response body. + * + * @return The HTTP response body. + */ + ByteBuffer getContents(); + + /** + * Gets the HTTP response body as an error. + * + * @return If there is no HTTP response body, an empty string is returned. + */ + String getError(); + + /** + * Gets whether or not the HTTP response is complete. An HTTP response is complete when the HTTP header and body are + * received. + * + * @return {@code true} if the HTTP response is complete, and {@code false} otherwise. + */ + boolean isMissingContent(); + + /** + * Adds contents to the body if it is missing content. + * + * @param contents Contents to add to the HTTP body. + */ + void addContent(ByteBuffer contents); +} diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/Constants.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/Constants.java index 28826a6..38fc662 100644 --- a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/Constants.java +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/Constants.java @@ -9,7 +9,7 @@ import java.util.Locale; * Package private constants. */ class Constants { - static final String PROXY_AUTHENTICATE_HEADER = "Proxy-Authenticate:"; + static final String PROXY_AUTHENTICATE = "Proxy-Authenticate"; static final String PROXY_AUTHORIZATION = "Proxy-Authorization"; static final String DIGEST = "Digest"; @@ -18,4 +18,11 @@ class Constants { static final String DIGEST_LOWERCASE = Constants.DIGEST.toLowerCase(Locale.ROOT); static final String CONNECT = "CONNECT"; + + static final String PROXY_CONNECT_FAILED = "Proxy connect request failed with error: "; + static final String PROXY_CONNECT_USER_ERROR = "User configuration error. Using non-matching proxy authentication."; + + static final int PROXY_HANDSHAKE_BUFFER_SIZE = 4 * 1024; // buffers used only for proxy-handshake + + static final String CONTENT_LENGTH = "Content-Length"; } diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/DigestProxyChallengeProcessorImpl.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/DigestProxyChallengeProcessorImpl.java index e8736a7..96def56 100644 --- a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/DigestProxyChallengeProcessorImpl.java +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/DigestProxyChallengeProcessorImpl.java @@ -28,7 +28,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; */ public class DigestProxyChallengeProcessorImpl implements ProxyChallengeProcessor { static final String DEFAULT_ALGORITHM = "MD5"; - private static final String PROXY_AUTH_DIGEST = Constants.PROXY_AUTHENTICATE_HEADER + " " + Constants.DIGEST; + private static final String PROXY_AUTH_DIGEST = Constants.DIGEST; private static final char[] HEX_CODE = "0123456789ABCDEF".toCharArray(); private static final SecureRandom SECURE_RANDOM = new SecureRandom(); diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyHandlerImpl.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyHandlerImpl.java index dd4bef1..3b2172c 100644 --- a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyHandlerImpl.java +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyHandlerImpl.java @@ -3,16 +3,19 @@ package com.microsoft.azure.proton.transport.proxy.impl; +import com.microsoft.azure.proton.transport.proxy.HttpStatusLine; import com.microsoft.azure.proton.transport.proxy.Proxy; import com.microsoft.azure.proton.transport.proxy.ProxyHandler; +import com.microsoft.azure.proton.transport.proxy.ProxyResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.Map; -import java.util.Scanner; -import java.util.function.Predicate; -import java.util.regex.Pattern; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Implementation class that handles connecting to the proxy. @@ -27,8 +30,10 @@ public class ProxyHandlerImpl implements ProxyHandler { static final String CONNECT_REQUEST = "CONNECT %1$s HTTP/1.1%2$sHost: %1$s%2$sConnection: Keep-Alive%2$s"; static final String HEADER_FORMAT = "%s: %s"; static final String NEW_LINE = "\r\n"; - private final Pattern successStatusLine = Pattern.compile("^http/1\\.(0|1) (?2[0-9]{2})", Pattern.CASE_INSENSITIVE); - private final Predicate successStatusLinePredicate = successStatusLine.asPredicate(); + + private static final String CONNECTION_ESTABLISHED = "connection established"; + private static final Set SUPPORTED_VERSIONS = Stream.of("1.1", "1.0").collect(Collectors.toSet()); + private final Logger logger = LoggerFactory.getLogger(ProxyHandlerImpl.class); /** * {@inheritDoc} @@ -54,23 +59,17 @@ public class ProxyHandlerImpl implements ProxyHandler { * {@inheritDoc} */ @Override - public ProxyResponseResult validateProxyResponse(ByteBuffer buffer) { - int size = buffer.remaining(); - String response = null; + public boolean validateProxyResponse(ProxyResponse response) { + Objects.requireNonNull(response, "'response' cannot be null."); - if (size > 0) { - byte[] responseBytes = new byte[buffer.remaining()]; - buffer.get(responseBytes); - response = new String(responseBytes, StandardCharsets.UTF_8); - final Scanner responseScanner = new Scanner(response); - if (responseScanner.hasNextLine()) { - final String firstLine = responseScanner.nextLine(); - if (successStatusLinePredicate.test(firstLine)) { - return new ProxyResponseResult(true, null); - } - } + final HttpStatusLine status = response.getStatus(); + if (status == null) { + logger.error("Response does not contain a status line. {}", response); + return false; } - return new ProxyResponseResult(false, response); + return status.getStatusCode() == 200 + && SUPPORTED_VERSIONS.contains(status.getProtocolVersion()) + && CONNECTION_ESTABLISHED.equalsIgnoreCase(status.getReason()); } } diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImpl.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImpl.java index eedaa6f..bd19ec3 100644 --- a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImpl.java +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImpl.java @@ -8,6 +8,7 @@ import com.microsoft.azure.proton.transport.proxy.ProxyAuthenticationType; import com.microsoft.azure.proton.transport.proxy.ProxyChallengeProcessor; import com.microsoft.azure.proton.transport.proxy.ProxyConfiguration; import com.microsoft.azure.proton.transport.proxy.ProxyHandler; +import com.microsoft.azure.proton.transport.proxy.ProxyResponse; import org.apache.qpid.proton.engine.Transport; import org.apache.qpid.proton.engine.TransportException; import org.apache.qpid.proton.engine.impl.TransportImpl; @@ -19,16 +20,23 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Scanner; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import static com.microsoft.azure.proton.transport.proxy.ProxyAuthenticationType.BASIC; import static com.microsoft.azure.proton.transport.proxy.ProxyAuthenticationType.DIGEST; +import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_AUTHENTICATE; +import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_CONNECT_FAILED; +import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_CONNECT_USER_ERROR; +import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_HANDSHAKE_BUFFER_SIZE; import static org.apache.qpid.proton.engine.impl.ByteBufferUtils.newWriteableBuffer; /** @@ -40,9 +48,6 @@ import static org.apache.qpid.proton.engine.impl.ByteBufferUtils.newWriteableBuf */ public class ProxyImpl implements Proxy, TransportLayer { private static final Logger LOGGER = LoggerFactory.getLogger(ProxyImpl.class); - private static final String PROXY_CONNECT_FAILED = "Proxy connect request failed with error: "; - private static final String PROXY_CONNECT_USER_ERROR = "User configuration error. Using non-matching proxy authentication."; - private static final int PROXY_HANDSHAKE_BUFFER_SIZE = 8 * 1024; // buffers used only for proxy-handshake private final ByteBuffer inputBuffer; private final ByteBuffer outputBuffer; @@ -50,28 +55,27 @@ public class ProxyImpl implements Proxy, TransportLayer { private boolean tailClosed = false; private boolean headClosed = false; - private boolean isProxyConfigured; private String host = ""; private Map headers = null; private TransportImpl underlyingTransport; - private ProxyState proxyState = ProxyState.PN_PROXY_NOT_STARTED; private ProxyHandler proxyHandler; + private volatile boolean isProxyConfigured; + private volatile ProxyState proxyState = ProxyState.PN_PROXY_NOT_STARTED; + /** - * Create proxy transport layer - which, after configuring using - * the {@link #configure(String, Map, ProxyHandler, Transport)} API - * is ready for layering in qpid-proton-j transport layers, using - * {@link org.apache.qpid.proton.engine.impl.TransportInternal#addTransportLayer(TransportLayer)} API. + * Create proxy transport layer - which, after configuring using the {@link #configure(String, Map, ProxyHandler, + * Transport)} API is ready for layering in qpid-proton-j transport layers, using {@link + * org.apache.qpid.proton.engine.impl.TransportInternal#addTransportLayer(TransportLayer)} API. */ public ProxyImpl() { this(null); } /** - * Create proxy transport layer - which, after configuring using - * the {@link #configure(String, Map, ProxyHandler, Transport)} API - * is ready for layering in qpid-proton-j transport layers, using - * {@link org.apache.qpid.proton.engine.impl.TransportInternal#addTransportLayer(TransportLayer)} API. + * Create proxy transport layer - which, after configuring using the {@link #configure(String, Map, ProxyHandler, + * Transport)} API is ready for layering in qpid-proton-j transport layers, using {@link + * org.apache.qpid.proton.engine.impl.TransportInternal#addTransportLayer(TransportLayer)} API. * * @param configuration Proxy configuration to use. */ @@ -133,7 +137,7 @@ public class ProxyImpl implements Proxy, TransportLayer { return this.outputBuffer; } - protected Boolean getIsProxyConfigured() { + protected boolean getIsProxyConfigured() { return this.isProxyConfigured; } @@ -180,10 +184,17 @@ public class ProxyImpl implements Proxy, TransportLayer { private final TransportOutput underlyingOutput; private final ByteBuffer head; + // Represents a response from a CONNECT request. + private final AtomicReference proxyResponse = new AtomicReference<>(); + + /** + * Creates a transport wrapper that wraps the WebSocket transport input and output. + */ ProxyTransportWrapper(TransportInput input, TransportOutput output) { underlyingInput = input; underlyingOutput = output; head = outputBuffer.asReadOnlyBuffer(); + head.limit(0); } @Override @@ -231,62 +242,78 @@ public class ProxyImpl implements Proxy, TransportLayer { switch (proxyState) { case PN_PROXY_CONNECTING: inputBuffer.flip(); - final ProxyHandler.ProxyResponseResult responseResult = proxyHandler.validateProxyResponse(inputBuffer); - inputBuffer.compact(); - inputBuffer.clear(); + final ProxyResponse connectResponse = readProxyResponse(inputBuffer); + + if (connectResponse == null || connectResponse.isMissingContent()) { + LOGGER.info("Request is missing content. Waiting for more bytes."); + break; + } + //Clean up response to prepare for challenge + proxyResponse.set(null); + + final boolean isSuccess = proxyHandler.validateProxyResponse(connectResponse); // When connecting to proxy, it does not challenge us for authentication. If the user has specified - // a configuration and it is not NONE, then we fail due to misconfiguration. - if (responseResult.getIsSuccess()) { + // a configuration, and it is not NONE, then we fail due to misconfiguration. + if (isSuccess) { if (proxyConfiguration == null || proxyConfiguration.authentication() == ProxyAuthenticationType.NONE) { proxyState = ProxyState.PN_PROXY_CONNECTED; } else { if (LOGGER.isErrorEnabled()) { LOGGER.error("ProxyConfiguration mismatch. User configured: '{}', but authentication is not required", - proxyConfiguration.authentication()); + proxyConfiguration.authentication()); } - closeTailProxyError(PROXY_CONNECT_USER_ERROR); } break; } - final String challenge = responseResult.getError(); - final Set supportedTypes = getAuthenticationTypes(challenge); + final Map> headers = connectResponse.getHeaders(); + final Set supportedTypes = getAuthenticationTypes(headers); // The proxy did not successfully connect, user has specified that they want a particular // authentication method, but it is not in list of supported authentication methods. if (proxyConfiguration != null && !supportedTypes.contains(proxyConfiguration.authentication())) { if (LOGGER.isErrorEnabled()) { LOGGER.error("Proxy authentication required. User configured: '{}', but supported proxy authentication methods are: {}", - proxyConfiguration.authentication(), - supportedTypes.stream().map(type -> type.toString()).collect(Collectors.joining(","))); + proxyConfiguration.authentication(), + supportedTypes.stream().map(type -> type.toString()).collect(Collectors.joining(","))); } - - closeTailProxyError(PROXY_CONNECT_USER_ERROR + PROXY_CONNECT_FAILED + challenge); + closeTailProxyError(PROXY_CONNECT_USER_ERROR + PROXY_CONNECT_FAILED + + connectResponse); break; } + final List challenges = headers.getOrDefault(PROXY_AUTHENTICATE, new ArrayList<>()); final ProxyChallengeProcessor processor = proxyConfiguration != null - ? getChallengeProcessor(host, challenge, proxyConfiguration.authentication()) - : getChallengeProcessor(host, challenge, supportedTypes); + ? getChallengeProcessor(host, challenges, proxyConfiguration.authentication()) + : getChallengeProcessor(host, challenges, supportedTypes); if (processor != null) { proxyState = ProxyState.PN_PROXY_CHALLENGE; - headers = processor.getHeader(); + ProxyImpl.this.headers = processor.getHeader(); } else { - closeTailProxyError(PROXY_CONNECT_FAILED + challenge); + LOGGER.warn("Could not get ProxyChallengeProcessor for challenges."); + closeTailProxyError(PROXY_CONNECT_FAILED + String.join(";", challenges)); } break; case PN_PROXY_CHALLENGE_RESPONDED: inputBuffer.flip(); - final ProxyHandler.ProxyResponseResult result = proxyHandler.validateProxyResponse(inputBuffer); - inputBuffer.compact(); + final ProxyResponse challengeResponse = readProxyResponse(inputBuffer); - if (result.getIsSuccess()) { + if (challengeResponse == null || challengeResponse.isMissingContent()) { + LOGGER.warn("Request is missing content. Waiting for more bytes."); + break; + } + //Clean up + proxyResponse.set(null); + + final boolean result = proxyHandler.validateProxyResponse(challengeResponse); + + if (result) { proxyState = ProxyState.PN_PROXY_CONNECTED; } else { - closeTailProxyError(PROXY_CONNECT_FAILED + result.getError()); + closeTailProxyError(PROXY_CONNECT_FAILED + challengeResponse); } break; default: @@ -353,6 +380,11 @@ public class ProxyImpl implements Proxy, TransportLayer { } } + /** + * Gets the beginning of the output buffer. + * + * @return The beginning of the byte buffer. + */ @Override public ByteBuffer head() { if (getIsHandshakeInProgress()) { @@ -368,6 +400,11 @@ public class ProxyImpl implements Proxy, TransportLayer { } } + /** + * Removes the first number of bytes from the output buffer. + * + * @param bytes The number of bytes to remove from the output buffer. + */ @Override public void pop(int bytes) { if (getIsHandshakeInProgress()) { @@ -392,6 +429,9 @@ public class ProxyImpl implements Proxy, TransportLayer { } } + /** + * Closes the output transport. + */ @Override public void close_head() { headClosed = true; @@ -402,18 +442,21 @@ public class ProxyImpl implements Proxy, TransportLayer { * Gets the ProxyChallengeProcessor based on authentication types supported. Prefers DIGEST authentication if * supported over BASIC. Returns null if it cannot match any supported types. */ - private ProxyChallengeProcessor getChallengeProcessor(String host, String challenge, + private ProxyChallengeProcessor getChallengeProcessor(String host, List challenges, Set authentication) { + final ProxyAuthenticationType authType; if (authentication.contains(DIGEST)) { - return getChallengeProcessor(host, challenge, DIGEST); + authType = DIGEST; } else if (authentication.contains(BASIC)) { - return getChallengeProcessor(host, challenge, BASIC); + authType = BASIC; } else { return null; } + + return getChallengeProcessor(host, challenges, authType); } - private ProxyChallengeProcessor getChallengeProcessor(String host, String challenge, + private ProxyChallengeProcessor getChallengeProcessor(String host, List challenges, ProxyAuthenticationType authentication) { final ProxyAuthenticator authenticator = proxyConfiguration != null ? new ProxyAuthenticator(proxyConfiguration) @@ -421,47 +464,45 @@ public class ProxyImpl implements Proxy, TransportLayer { switch (authentication) { case DIGEST: - return new DigestProxyChallengeProcessorImpl(host, challenge, authenticator); + final Optional matching = challenges.stream() + .filter(challenge -> challenge.toLowerCase(Locale.ROOT).startsWith(Constants.DIGEST_LOWERCASE)) + .findFirst(); + + return matching.map(c -> new DigestProxyChallengeProcessorImpl(host, c, authenticator)) + .orElse(null); case BASIC: return new BasicProxyChallengeProcessorImpl(host, authenticator); default: + LOGGER.warn("Authentication type does not have a challenge processor: {}", authentication); return null; } } /** - * Gets the supported authentication types based on the {@code error}. + * Gets the supported authentication types based on the {@code headers}. * - * @param error Response from service call. - * @return The supported proxy authentication methods. Or, an empty array if the value of {@code error} is - * {@code null}, an empty string. Also, if it does not contain {@link Constants#PROXY_AUTHENTICATE_HEADER} with - * {@link Constants#BASIC_LOWERCASE} or {@link Constants#DIGEST_LOWERCASE}. + * @param headers HTTP proxy response headers from service call. + * @return The supported proxy authentication methods. Or, an empty set if the value of {@code error} is {@code + * null}, an empty string. Or, if it does not contain{@link Constants#PROXY_AUTHENTICATE} with + * {@link Constants#BASIC_LOWERCASE} or {@link Constants#DIGEST_LOWERCASE}. */ - private Set getAuthenticationTypes(String error) { - int index = error.indexOf(Constants.PROXY_AUTHENTICATE_HEADER); - - if (index == -1) { + private Set getAuthenticationTypes(Map> headers) { + if (!headers.containsKey(PROXY_AUTHENTICATE)) { return Collections.emptySet(); } - Set supportedTypes = new HashSet<>(); + final Set supportedTypes = new HashSet<>(); + final List authenticationTypes = headers.get(PROXY_AUTHENTICATE); - try (Scanner scanner = new Scanner(error)) { - while (scanner.hasNextLine()) { - String line = scanner.nextLine().trim(); + for (String type : authenticationTypes) { + final String lowercase = type.toLowerCase(Locale.ROOT); - if (!line.startsWith(Constants.PROXY_AUTHENTICATE_HEADER)) { - continue; - } - - String substring = line.substring(Constants.PROXY_AUTHENTICATE_HEADER.length()) - .trim().toLowerCase(Locale.ROOT); - - if (substring.startsWith(Constants.BASIC_LOWERCASE)) { - supportedTypes.add(BASIC); - } else if (substring.startsWith(Constants.DIGEST_LOWERCASE)) { - supportedTypes.add(DIGEST); - } + if (lowercase.startsWith(Constants.BASIC_LOWERCASE)) { + supportedTypes.add(BASIC); + } else if (lowercase.startsWith(Constants.DIGEST_LOWERCASE)) { + supportedTypes.add(DIGEST); + } else { + LOGGER.warn("Did not understand this authentication type: {}", type); } } @@ -472,5 +513,31 @@ public class ProxyImpl implements Proxy, TransportLayer { tailClosed = true; underlyingTransport.closed(new TransportException(errorMessage)); } + + /** + * Given a byte buffer, reads a HTTP proxy response from it. + * + * @param buffer The buffer to read HTTP proxy response from. + * @return The current HTTP proxy response. Or {@code null} if one could not be read from the buffer and there + * is no current HTTP response. + */ + private ProxyResponse readProxyResponse(ByteBuffer buffer) { + int size = buffer.remaining(); + if (size <= 0) { + LOGGER.warn("InputBuffer is empty. Not reading any contents from it. Returning current response."); + return proxyResponse.get(); + } + + ProxyResponse current = proxyResponse.get(); + if (current == null) { + proxyResponse.set(ProxyResponseImpl.create(buffer)); + } else { + current.addContent(buffer); + } + + buffer.compact(); + + return proxyResponse.get(); + } } } diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyResponseImpl.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyResponseImpl.java new file mode 100644 index 0000000..534ec7f --- /dev/null +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyResponseImpl.java @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.proton.transport.proxy.impl; + +import com.microsoft.azure.proton.transport.proxy.HttpStatusLine; +import com.microsoft.azure.proton.transport.proxy.ProxyResponse; +import com.microsoft.azure.proton.transport.ws.WebSocket.WebSocketFrameReadState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static com.microsoft.azure.proton.transport.proxy.impl.Constants.CONTENT_LENGTH; + +/** + * Represents an HTTP response from a proxy. + * + * @see RFC2616 + */ +public final class ProxyResponseImpl implements ProxyResponse { + private static final Logger LOGGER = LoggerFactory.getLogger(ProxyResponseImpl.class); + + private final HttpStatusLine status; + private final Map> headers; + private final ByteBuffer contents; + + private ProxyResponseImpl(HttpStatusLine status, Map> headers, ByteBuffer contents) { + this.status = status; + this.headers = headers; + this.contents = contents; + } + + /** + * Create a proxy response from a given {@code buffer}. Assumes that the {@code buffer} has been flipped. + * + * @param buffer Buffer which could parse to a proxy response. + * @return A new instance of {@link ProxyResponseImpl} representing the given buffer. + * @throws IllegalArgumentException if {@code buffer} have no content to read. + */ + public static ProxyResponse create(ByteBuffer buffer) { + // Because we've flipped the buffer, position = 0, and the limit = size of the content. + int size = buffer.remaining(); + + if (size <= 0) { + throw new IllegalArgumentException(String.format("Cannot create response with buffer have no content. " + + "Limit: %s. Position: %s. Cap: %s", buffer.limit(), buffer.position(), buffer.capacity())); + } + + final byte[] responseBytes = new byte[size]; + buffer.get(responseBytes); + + final String response = new String(responseBytes, StandardCharsets.UTF_8); + final String[] lines = response.split(StringUtils.NEW_LINE); + final Map> headers = new HashMap<>(); + + WebSocketFrameReadState frameReadState = WebSocketFrameReadState.INIT_READ; + HttpStatusLine statusLine = null; + ByteBuffer contents = ByteBuffer.allocate(0); + + //Assume the full header message is in the first frame + for (String line : lines) { + switch (frameReadState) { + case INIT_READ: + statusLine = HttpStatusLine.create(line); + frameReadState = WebSocketFrameReadState.CHUNK_READ; + break; + case CHUNK_READ: + if (StringUtils.isNullOrEmpty(line)) { + // Now that we're done reading all the headers, figure out the size of the HTTP body and + // allocate an array of that size. + int length = 0; + if (headers.containsKey(CONTENT_LENGTH)) { + final List contentLength = headers.get(CONTENT_LENGTH); + length = Integer.parseInt(contentLength.get(0)); + } + + boolean hasBody = length > 0; + if (!hasBody) { + LOGGER.info("There is no content in the response. Response: {}", response); + return new ProxyResponseImpl(statusLine, headers, contents); + } + + contents = ByteBuffer.allocate(length); + frameReadState = WebSocketFrameReadState.HEADER_READ; + } else { + final Map.Entry header = parseHeader(line); + final List value = headers.getOrDefault(header.getKey(), new ArrayList<>()); + + value.add(header.getValue()); + headers.put(header.getKey(), value); + } + break; + case HEADER_READ: + if (contents.position() == 0) { + frameReadState = WebSocketFrameReadState.CONTINUED_FRAME_READ; + } + + contents.put(line.getBytes(StandardCharsets.UTF_8)); + contents.mark(); + break; + case CONTINUED_FRAME_READ: + contents.put(line.getBytes(StandardCharsets.UTF_8)); + contents.mark(); + break; + default: + LOGGER.error("Unknown state: {}. Response: {}", frameReadState, response); + frameReadState = WebSocketFrameReadState.READ_ERROR; + break; + } + } + + + return new ProxyResponseImpl(statusLine, headers, contents); + } + + private static Map.Entry parseHeader(String contents) { + final String[] split = contents.split(":", 2); + + if (split.length != 2) { + throw new IllegalStateException("Line is not a valid header. Contents: " + contents); + } + + return new AbstractMap.SimpleEntry<>(split[0].trim(), split[1].trim()); + } + + /** + * {@inheritDoc} + */ + public HttpStatusLine getStatus() { + return status; + } + + /** + * {@inheritDoc} + */ + public Map> getHeaders() { + return headers; + } + + /** + * {@inheritDoc} + */ + public ByteBuffer getContents() { + return contents.duplicate(); + } + + /** + * {@inheritDoc} + */ + public String getError() { + final ByteBuffer readonly = contents.asReadOnlyBuffer(); + readonly.flip(); + return StandardCharsets.UTF_8.decode(readonly).toString(); + } + + /** + * Gets whether or not the HTTP response is complete. An HTTP response is complete when the HTTP header and body are + * received. + * + * @return {@code true} if the HTTP response is complete, and {@code false} otherwise. + */ + public boolean isMissingContent() { + return contents.hasRemaining(); + } + + /** + * Adds additional content to the HTTP response's body. Assumes that the {@code content} has been flipped. + * + * @param content Content to add to the body of the HTTP response. + * @throws NullPointerException if {@code content} is {@code null}. + * @throws IllegalArgumentException if {@code content} have no content to read. + */ + public void addContent(ByteBuffer content) { + Objects.requireNonNull(content, "'content' cannot be null."); + + int size = content.remaining(); + + if (size <= 0) { + throw new IllegalArgumentException("There was no content to add to current HTTP response."); + } + + final byte[] responseBytes = new byte[content.remaining()]; + content.get(responseBytes); + + this.contents.put(responseBytes); + } + +} diff --git a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/StringUtils.java b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/StringUtils.java index 9af33ac..95a61c1 100644 --- a/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/StringUtils.java +++ b/src/main/java/com/microsoft/azure/proton/transport/proxy/impl/StringUtils.java @@ -7,6 +7,8 @@ package com.microsoft.azure.proton.transport.proxy.impl; * Utility classes for strings. */ class StringUtils { + static final String NEW_LINE = "\r\n"; + static boolean isNullOrEmpty(String string) { return string == null || string.isEmpty(); } diff --git a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/DigestProxyChallengeProcessorImplTest.java b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/DigestProxyChallengeProcessorImplTest.java index 9c0ff0d..35f14a3 100644 --- a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/DigestProxyChallengeProcessorImplTest.java +++ b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/DigestProxyChallengeProcessorImplTest.java @@ -180,10 +180,10 @@ public class DigestProxyChallengeProcessorImplTest { } private static String generateProxyChallenge(String realm, String nonce, String qop) { - final String digest = String.format("%s %s realm=\"%s\", nonce=\"%s\", qop=\"%s\", stale=false", - Constants.PROXY_AUTHENTICATE_HEADER, Constants.DIGEST, realm, nonce, qop); - final String basic = String.format("%s %s realm=\"%s\"", - Constants.PROXY_AUTHENTICATE_HEADER, Constants.BASIC, realm); + final String digest = String.format("%s realm=\"%s\", nonce=\"%s\", qop=\"%s\", stale=false", + Constants.DIGEST, realm, nonce, qop); + final String basic = String.format("%s realm=\"%s\"", + Constants.BASIC, realm); return String.join(NEW_LINE, "HTTP/1.1 407 Proxy Authentication Required", diff --git a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/HttpStatusLineTest.java b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/HttpStatusLineTest.java new file mode 100644 index 0000000..3ccff74 --- /dev/null +++ b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/HttpStatusLineTest.java @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.proton.transport.proxy.impl; + +import com.microsoft.azure.proton.transport.proxy.HttpStatusLine; +import org.junit.Assert; +import org.junit.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class HttpStatusLineTest { + + /** + * Verifies that it successfully parses a valid HTTP status line. + */ + @Test + public void validStatusLine() { + // Arrange + final String line = "HTTP/1.1 200 Connection Established"; + + // Act + final HttpStatusLine actual = HttpStatusLine.create(line); + + // Assert + Assert.assertNotNull(actual); + + Assert.assertEquals(200, actual.getStatusCode()); + Assert.assertEquals("1.1", actual.getProtocolVersion()); + Assert.assertEquals("Connection Established", actual.getReason()); + } + + + /** + * Verifies that status line length is invalid + */ + @ParameterizedTest + @ValueSource(strings = {"HTTP/1.1 InvalidLength", "HTTP/1.1 Invalid Code", "HTTP/invalid protocol"}) + public void invalidStatusLine(String line) { + + // Act & Assert + Assert.assertThrows(IllegalArgumentException.class, () -> HttpStatusLine.create(line)); + } + + +} diff --git a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyHandlerImplTest.java b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyHandlerImplTest.java index 29552ae..6e3431c 100644 --- a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyHandlerImplTest.java +++ b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyHandlerImplTest.java @@ -3,16 +3,19 @@ package com.microsoft.azure.proton.transport.proxy.impl; -import com.microsoft.azure.proton.transport.proxy.ProxyHandler; +import com.microsoft.azure.proton.transport.proxy.HttpStatusLine; +import com.microsoft.azure.proton.transport.proxy.ProxyResponse; import org.junit.Assert; import org.junit.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.util.HashMap; +import static com.microsoft.azure.proton.transport.proxy.impl.StringUtils.NEW_LINE; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + public class ProxyHandlerImplTest { @Test public void testCreateProxyRequest() { @@ -34,79 +37,63 @@ public class ProxyHandlerImplTest { Assert.assertEquals(expectedProxyRequest, actualProxyRequest); } - @ParameterizedTest - @ValueSource(ints = {200, 201, 202, 203, 204, 205, 206}) - public void testValidateProxyResponseOnSuccess(int httpCode) { - final String validResponse = "HTTP/1.1 " + httpCode + "Connection Established\r\n" - + "FiddlerGateway: Direct\r\n" - + "StartTime: 13:08:21.574\r\n" - + "Connection: close"; - final ByteBuffer buffer = ByteBuffer.allocate(1024); - buffer.put(validResponse.getBytes(StandardCharsets.UTF_8)); - buffer.flip(); - + @Test + public void testValidateProxyResponseOnSuccess() { + // Arrange + final HttpStatusLine statusLine = HttpStatusLine.create("HTTP/1.1 200 Connection Established"); + final ProxyResponse response = mock(ProxyResponse.class); + when(response.isMissingContent()).thenReturn(false); + when(response.getStatus()).thenReturn(statusLine); final ProxyHandlerImpl proxyHandler = new ProxyHandlerImpl(); - ProxyHandler.ProxyResponseResult responseResult = proxyHandler.validateProxyResponse(buffer); - Assert.assertTrue(responseResult.getIsSuccess()); - Assert.assertNull(responseResult.getError()); + // Act + final boolean result = proxyHandler.validateProxyResponse(response); + + // Assert + Assert.assertTrue(result); - Assert.assertEquals(0, buffer.remaining()); } @Test public void testValidateProxyResponseOnFailure() { - final String failResponse = String.join("\r\n", "HTTP/1.1 407 Proxy Auth Required", - "Connection: close", - "Proxy-Authenticate: Basic realm=\\\"FiddlerProxy (user: 1, pass: 1)\\", - "Content-Type: text/html", - "[Fiddler] Proxy Authentication Required.
\r\n"); - final ByteBuffer buffer = ByteBuffer.allocate(1024); - buffer.put(failResponse.getBytes(StandardCharsets.UTF_8)); - buffer.flip(); + // Arrange + final HttpStatusLine statusLine = HttpStatusLine.create("HTTP/1.1 407 Proxy Auth Required"); + final String contents = "[Fiddler] Proxy Authentication Required.
"; + final ByteBuffer encoded = UTF_8.encode(contents); + final ProxyResponse response = mock(ProxyResponse.class); + when(response.isMissingContent()).thenReturn(false); + when(response.getStatus()).thenReturn(statusLine); + when(response.getContents()).thenReturn(encoded); + when(response.getError()).thenReturn(contents); final ProxyHandlerImpl proxyHandler = new ProxyHandlerImpl(); - ProxyHandler.ProxyResponseResult responseResult = proxyHandler.validateProxyResponse(buffer); - Assert.assertTrue(!responseResult.getIsSuccess()); - Assert.assertEquals(failResponse, responseResult.getError()); + // Act + final boolean result = proxyHandler.validateProxyResponse(response); - Assert.assertEquals(0, buffer.remaining()); - } - - @Test - public void testValidateProxyResponseOnInvalidResponse() { - final String invalidResponse = String.join("\r\n", "HTTP/1.1 abc Connection Established", - "HTTP/1.1 200 Connection Established", - "FiddlerGateway: Direct", - "StartTime: 13:08:21.574", - "Connection: close\r\n"); - final ByteBuffer buffer = ByteBuffer.allocate(1024); - buffer.put(invalidResponse.getBytes(StandardCharsets.UTF_8)); - buffer.flip(); - - final ProxyHandlerImpl proxyHandler = new ProxyHandlerImpl(); - ProxyHandler.ProxyResponseResult responseResult = proxyHandler.validateProxyResponse(buffer); - - Assert.assertTrue(!responseResult.getIsSuccess()); - Assert.assertEquals(invalidResponse, responseResult.getError()); - - Assert.assertEquals(0, buffer.remaining()); + // Assert + Assert.assertFalse(result); } @Test public void testValidateProxyResponseOnEmptyResponse() { - final String emptyResponse = "\r\n\r\n"; + final String emptyResponse = NEW_LINE + NEW_LINE; final ByteBuffer buffer = ByteBuffer.allocate(1024); - buffer.put(emptyResponse.getBytes(StandardCharsets.UTF_8)); + buffer.put(emptyResponse.getBytes(UTF_8)); buffer.flip(); + final ProxyResponse response = mock(ProxyResponse.class); + when(response.isMissingContent()).thenReturn(false); + when(response.getStatus()).thenReturn(null); + when(response.getContents()).thenReturn(buffer); + when(response.getError()).thenReturn(emptyResponse); + final ProxyHandlerImpl proxyHandler = new ProxyHandlerImpl(); - ProxyHandler.ProxyResponseResult responseResult = proxyHandler.validateProxyResponse(buffer); - Assert.assertTrue(!responseResult.getIsSuccess()); - Assert.assertEquals(emptyResponse, responseResult.getError()); + // Act + final boolean result = proxyHandler.validateProxyResponse(response); - Assert.assertEquals(0, buffer.remaining()); + // Assert + Assert.assertFalse(result); } } diff --git a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImplTest.java b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImplTest.java index 0efbc8e..86801c5 100644 --- a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImplTest.java +++ b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyImplTest.java @@ -18,6 +18,9 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,10 +39,11 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import static com.microsoft.azure.proton.transport.proxy.impl.Constants.BASIC; import static com.microsoft.azure.proton.transport.proxy.impl.Constants.DIGEST; -import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_AUTHENTICATE_HEADER; +import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_AUTHENTICATE; import static com.microsoft.azure.proton.transport.proxy.impl.Constants.PROXY_AUTHORIZATION; import static org.mockito.Mockito.any; import static org.mockito.Mockito.argThat; @@ -59,14 +63,20 @@ import static org.mockito.Mockito.when; public class ProxyImplTest { private static final InetSocketAddress PROXY_ADDRESS = InetSocketAddress.createUnresolved("my.host.name", 8888); private static final java.net.Proxy PROXY = new java.net.Proxy(java.net.Proxy.Type.HTTP, PROXY_ADDRESS); - private static final int BUFFER_SIZE = 8 * 1024; + private static final int BUFFER_SIZE = Constants.PROXY_HANDSHAKE_BUFFER_SIZE; private static final String USERNAME = "test-user"; private static final String PASSWORD = "test-password!"; + private static final String BASIC_HEADER = BASIC; + private static final String DIGEST_HEADER = String.format("%s realm=\"%s\", nonce=\"A randomly set nonce.\", qop=\"auth\", stale=false", + DIGEST, PROXY); private final Logger logger = LoggerFactory.getLogger(ProxyImplTest.class); private final Map headers = new HashMap<>(); private ProxySelector originalProxy; + @Captor + private ArgumentCaptor> additionalHeaders; + private void initHeaders() { headers.put("header1", "value1"); headers.put("header2", "value2"); @@ -75,6 +85,8 @@ public class ProxyImplTest { @Before public void setup() { + MockitoAnnotations.initMocks(this); + originalProxy = ProxySelector.getDefault(); ProxySelector.setDefault(new ProxySelector() { @@ -107,6 +119,7 @@ public class ProxyImplTest { @After public void teardown() { + Mockito.framework().clearInlineMocks(); ProxySelector.setDefault(originalProxy); } @@ -326,13 +339,14 @@ public class ProxyImplTest { proxyImpl.configure(PROXY_ADDRESS.getHostName(), headers, mockHandler, mock(TransportImpl.class)); TransportInput mockInput = mock(TransportInput.class); TransportWrapper transportWrapper = proxyImpl.wrap(mockInput, mock(TransportOutput.class)); - ProxyHandler.ProxyResponseResult mockResponse = mock(ProxyHandler.ProxyResponseResult.class); when(mockHandler.createProxyRequest(any(), any())).thenReturn("proxy request"); - when(mockResponse.getIsSuccess()).thenReturn(true); - when(mockResponse.getError()).thenReturn(null); - when(mockHandler.validateProxyResponse(any())).thenReturn(mockResponse); + when(mockHandler.validateProxyResponse(any())).thenReturn(true); + + final String[] statusLine = new String[]{"HTTP/1.1", "200", "Connection Established"}; + String response = getProxyResponse(statusLine, new ArrayList<>()); + setInputBuffer(proxyImpl, response); Assert.assertEquals(Proxy.ProxyState.PN_PROXY_NOT_STARTED, proxyImpl.getProxyState()); transportWrapper.pending(); @@ -351,13 +365,14 @@ public class ProxyImplTest { proxyImpl.configure(PROXY_ADDRESS.getHostName(), headers, mockHandler, mockTransport); TransportInput mockInput = mock(TransportInput.class); TransportWrapper transportWrapper = proxyImpl.wrap(mockInput, mock(TransportOutput.class)); - ProxyHandler.ProxyResponseResult mockResponse = mock(ProxyHandler.ProxyResponseResult.class); when(mockHandler.createProxyRequest(any(), any())).thenReturn("proxy request"); - when(mockResponse.getIsSuccess()).thenReturn(false); - when(mockResponse.getError()).thenReturn("proxy failure response"); - when(mockHandler.validateProxyResponse(any())).thenReturn(mockResponse); + final String[] statusLine = new String[]{"HTTP/1.1", "500", "Internal Server Error"}; + final List authentications = new ArrayList<>(); + String response = getProxyResponse(statusLine, authentications); + setInputBuffer(proxyImpl, response); + when(mockHandler.validateProxyResponse(any())).thenReturn(false); Assert.assertEquals(Proxy.ProxyState.PN_PROXY_NOT_STARTED, proxyImpl.getProxyState()); transportWrapper.pending(); @@ -679,13 +694,15 @@ public class ProxyImplTest { proxyImpl.configure(PROXY_ADDRESS.getHostName(), headers, handler, underlyingTransport); TransportInput input = mock(TransportInput.class); TransportWrapper transportWrapper = proxyImpl.wrap(input, mock(TransportOutput.class)); - ProxyHandler.ProxyResponseResult mockResponse = mock(ProxyHandler.ProxyResponseResult.class); when(handler.createProxyRequest(any(), any())).thenReturn("proxy request"); - when(handler.validateProxyResponse(any())).thenReturn(mockResponse); + when(handler.validateProxyResponse(any())).thenReturn(false); - when(mockResponse.getIsSuccess()).thenReturn(false); - when(mockResponse.getError()).thenReturn(getProxyChallenge(true, false)); + final String[] statusLine = new String[]{"HTTP/1.1", "407", "Proxy Authentication Required"}; + final List authentications = new ArrayList<>(); + authentications.add(BASIC_HEADER); + final String response = getProxyResponse(statusLine, authentications); + setInputBuffer(proxyImpl, response); // Act and Assert Assert.assertEquals(Proxy.ProxyState.PN_PROXY_NOT_STARTED, proxyImpl.getProxyState()); @@ -699,9 +716,7 @@ public class ProxyImplTest { } /** - * Verifies that if we explicitly set ProxyAuthenticationType.NONE and the proxy asks for verification then we fail. - * This also covers the case where the proxy configuration suggests one auth method, but it is not supported in the - * proxy challenge. + * Verifies that if we configure proxy authentication type but the proxy does not ask for verification then we fail. */ @Test public void authenticationNoAuthMismatchClosesTail() { @@ -713,13 +728,14 @@ public class ProxyImplTest { proxyImpl.configure(PROXY_ADDRESS.getHostName(), headers, handler, underlyingTransport); TransportInput input = mock(TransportInput.class); TransportWrapper transportWrapper = proxyImpl.wrap(input, mock(TransportOutput.class)); - ProxyHandler.ProxyResponseResult mockResponse = mock(ProxyHandler.ProxyResponseResult.class); when(handler.createProxyRequest(any(), any())).thenReturn("proxy request"); - when(handler.validateProxyResponse(any())).thenReturn(mockResponse); + when(handler.validateProxyResponse(any())).thenReturn(true); + + final String[] statusLine = new String[]{"HTTP/1.1", "200", "Connection Established"}; + String response = getProxyResponse(statusLine, new ArrayList<>()); + setInputBuffer(proxyImpl, response); - when(mockResponse.getIsSuccess()).thenReturn(true); - when(mockResponse.getError()).thenReturn(null); // Act and Assert Assert.assertEquals(Proxy.ProxyState.PN_PROXY_NOT_STARTED, proxyImpl.getProxyState()); @@ -737,7 +753,6 @@ public class ProxyImplTest { * configured auth method. */ @Test - @SuppressWarnings({"unchecked", "rawtypes"}) public void authenticationWithProxyConfiguration() { // Arrange ProxyConfiguration configuration = new ProxyConfiguration(ProxyAuthenticationType.BASIC, PROXY, USERNAME, PASSWORD); @@ -747,13 +762,16 @@ public class ProxyImplTest { TransportOutput output = mock(TransportOutput.class); proxyImpl.configure(PROXY_ADDRESS.getHostName(), headers, handler, underlyingTransport); TransportWrapper transportWrapper = proxyImpl.wrap(mock(TransportInput.class), output); - ProxyHandler.ProxyResponseResult mockResponse = mock(ProxyHandler.ProxyResponseResult.class); when(handler.createProxyRequest(any(), any())).thenReturn("proxy request", "proxy request2"); - when(handler.validateProxyResponse(any())).thenReturn(mockResponse); + when(handler.validateProxyResponse(any())).thenReturn(false, true); - when(mockResponse.getIsSuccess()).thenReturn(false, true); - when(mockResponse.getError()).thenReturn(getProxyChallenge(true, true), "Success."); + String[] statusLine = new String[]{"HTTP/1.1", "407", "Proxy Authentication Required"}; + List authentications = new ArrayList<>(); + authentications.add(BASIC_HEADER); + authentications.add(DIGEST_HEADER); + String response = getProxyResponse(statusLine, authentications); + setInputBuffer(proxyImpl, response); // Act and Assert Assert.assertEquals(Proxy.ProxyState.PN_PROXY_NOT_STARTED, proxyImpl.getProxyState()); @@ -769,6 +787,10 @@ public class ProxyImplTest { clearOutputBuffer(proxyImpl); transportWrapper.pending(); + statusLine = new String[]{"HTTP/1.1", "200", "Connection Established"}; + response = getProxyResponse(statusLine, new ArrayList<>()); + setInputBuffer(proxyImpl, response); + Assert.assertTrue(proxyImpl.getIsHandshakeInProgress()); Assert.assertEquals(Proxy.ProxyState.PN_PROXY_CHALLENGE_RESPONDED, proxyImpl.getProxyState()); transportWrapper.process(); @@ -776,46 +798,42 @@ public class ProxyImplTest { Assert.assertFalse(proxyImpl.getIsHandshakeInProgress()); Assert.assertEquals(Proxy.ProxyState.PN_PROXY_CONNECTED, proxyImpl.getProxyState()); - ArgumentCaptor captor = ArgumentCaptor.forClass(Map.class); verify(handler, times(2)).createProxyRequest( - argThat(string -> string != null && string.equals(PROXY_ADDRESS.getHostName())), (Map) captor.capture()); + argThat(string -> string != null && string.equals(PROXY_ADDRESS.getHostName())), additionalHeaders.capture()); - boolean foundHeader = false; - for (Map map : captor.getAllValues()) { - if (!map.containsKey(PROXY_AUTHORIZATION)) { - continue; - } - String value = (String) map.get(PROXY_AUTHORIZATION); - if (value.trim().startsWith(BASIC)) { - foundHeader = true; - break; - } - } + final Optional> matching = additionalHeaders.getAllValues() + .stream() + .filter(map -> map.containsKey(PROXY_AUTHORIZATION) + && map.get(PROXY_AUTHORIZATION).trim().startsWith(BASIC)) + .findFirst(); - Assert.assertTrue(foundHeader); + Assert.assertTrue(matching.isPresent()); } /** - * Verifies that when we use the system defaults and both are offered, then we will use the the DIGEST. + * Verifies that when we use the system defaults and both are offered, then we will use the DIGEST. */ @Test - @SuppressWarnings({"unchecked", "rawtypes"}) public void authenticationWithSystemDefaults() { // Arrange ProxyImpl proxyImpl = new ProxyImpl(); ProxyHandler handler = mock(ProxyHandler.class); TransportImpl underlyingTransport = mock(TransportImpl.class); TransportOutput output = mock(TransportOutput.class); + TransportInput transportInput = mock(TransportInput.class); proxyImpl.configure(PROXY_ADDRESS.getHostName(), headers, handler, underlyingTransport); - TransportWrapper transportWrapper = proxyImpl.wrap(mock(TransportInput.class), output); - ProxyHandler.ProxyResponseResult mockResponse = mock(ProxyHandler.ProxyResponseResult.class); + TransportWrapper transportWrapper = proxyImpl.wrap(transportInput, output); when(handler.createProxyRequest(any(), any())).thenReturn("proxy request", "proxy request2"); - when(handler.validateProxyResponse(any())).thenReturn(mockResponse); + when(handler.validateProxyResponse(any())).thenReturn(false, true); - when(mockResponse.getIsSuccess()).thenReturn(false, true); - when(mockResponse.getError()).thenReturn(getProxyChallenge(true, true), "Success."); + String[] statusLine = new String[]{"HTTP/1.1", "407", "Proxy Authentication Required"}; + List authentications = new ArrayList<>(); + authentications.add(BASIC_HEADER); + authentications.add(DIGEST_HEADER); + String response = getProxyResponse(statusLine, authentications); + setInputBuffer(proxyImpl, response); // Act and Assert Assert.assertEquals(Proxy.ProxyState.PN_PROXY_NOT_STARTED, proxyImpl.getProxyState()); @@ -831,6 +849,10 @@ public class ProxyImplTest { clearOutputBuffer(proxyImpl); transportWrapper.pending(); + statusLine = new String[]{"HTTP/1.1", "200", "Connection Established"}; + response = getProxyResponse(statusLine, new ArrayList<>()); + setInputBuffer(proxyImpl, response); + Assert.assertTrue(proxyImpl.getIsHandshakeInProgress()); Assert.assertEquals(Proxy.ProxyState.PN_PROXY_CHALLENGE_RESPONDED, proxyImpl.getProxyState()); transportWrapper.process(); @@ -838,24 +860,87 @@ public class ProxyImplTest { Assert.assertFalse(proxyImpl.getIsHandshakeInProgress()); Assert.assertEquals(Proxy.ProxyState.PN_PROXY_CONNECTED, proxyImpl.getProxyState()); - ArgumentCaptor captor = ArgumentCaptor.forClass(Map.class); verify(handler, times(2)).createProxyRequest( - argThat(string -> string != null && string.equals(PROXY_ADDRESS.getHostName())), (Map) captor.capture()); + argThat(string -> string != null && string.equals(PROXY_ADDRESS.getHostName())), additionalHeaders.capture()); - boolean foundHeader = false; - for (Map map : captor.getAllValues()) { - if (!map.containsKey(PROXY_AUTHORIZATION)) { - continue; - } + final Optional> matching = additionalHeaders.getAllValues() + .stream() + .filter(map -> map.containsKey(PROXY_AUTHORIZATION) + && map.get(PROXY_AUTHORIZATION).trim().startsWith(DIGEST)) + .findFirst(); - String value = (String) map.get(PROXY_AUTHORIZATION); - if (value.trim().startsWith(DIGEST)) { - foundHeader = true; - break; - } + Assert.assertTrue(matching.isPresent()); + } + + + /** + * Verifies that when proxy authentication response are transfer in multiple frames. + */ + @Test + public void authenticationResponseWithMultipleFrames() { + // Arrange + ProxyImpl proxyImpl = new ProxyImpl(); + ProxyHandler handler = mock(ProxyHandler.class); + TransportImpl underlyingTransport = mock(TransportImpl.class); + TransportOutput output = mock(TransportOutput.class); + TransportInput transportInput = mock(TransportInput.class); + proxyImpl.configure(PROXY_ADDRESS.getHostName(), headers, handler, underlyingTransport); + TransportWrapper transportWrapper = proxyImpl.wrap(transportInput, output); + + when(handler.createProxyRequest(any(), any())).thenReturn("proxy request", "proxy request2"); + when(handler.validateProxyResponse(any())).thenReturn(false, true); + + String[] statusLine = new String[]{"HTTP/1.1", "407", "Proxy Authentication Required"}; + List authentications = new ArrayList<>(); + authentications.add(BASIC_HEADER); + authentications.add(DIGEST_HEADER); + //Create a body which over buffer size so that it could be cut into multiple frames + //Here body is (buffer size * 2) bytes, and consider header size, + //it will create 3 frames for proxy response + String body = new String(new char[BUFFER_SIZE]).replace('\0', 't'); + List responses = getProxyResponseFrames(statusLine, authentications, body); + + // Act and Assert + Assert.assertEquals(3, responses.size()); + + Assert.assertEquals(Proxy.ProxyState.PN_PROXY_NOT_STARTED, proxyImpl.getProxyState()); + transportWrapper.pending(); + + for (String response : responses) { + setInputBuffer(proxyImpl, response); + Assert.assertTrue(proxyImpl.getIsHandshakeInProgress()); + Assert.assertEquals(Proxy.ProxyState.PN_PROXY_CONNECTING, proxyImpl.getProxyState()); + transportWrapper.process(); } - Assert.assertTrue(foundHeader); + + // At this point, we've gotten the correct challenger and set the header we want to respond with. We want to + // zero out the output buffer so that it'll write the headers when getting the request from the proxy handler. + Assert.assertEquals(Proxy.ProxyState.PN_PROXY_CHALLENGE, proxyImpl.getProxyState()); + clearOutputBuffer(proxyImpl); + transportWrapper.pending(); + + statusLine = new String[]{"HTTP/1.1", "200", "Connection Established"}; + String response = getProxyResponse(statusLine, new ArrayList<>()); + setInputBuffer(proxyImpl, response); + + Assert.assertTrue(proxyImpl.getIsHandshakeInProgress()); + Assert.assertEquals(Proxy.ProxyState.PN_PROXY_CHALLENGE_RESPONDED, proxyImpl.getProxyState()); + transportWrapper.process(); + + Assert.assertFalse(proxyImpl.getIsHandshakeInProgress()); + Assert.assertEquals(Proxy.ProxyState.PN_PROXY_CONNECTED, proxyImpl.getProxyState()); + + verify(handler, times(2)).createProxyRequest( + argThat(string -> string != null && string.equals(PROXY_ADDRESS.getHostName())), additionalHeaders.capture()); + + final Optional> matching = additionalHeaders.getAllValues() + .stream() + .filter(map -> map.containsKey(PROXY_AUTHORIZATION) + && map.get(PROXY_AUTHORIZATION).trim().startsWith(DIGEST)) + .findFirst(); + + Assert.assertTrue(matching.isPresent()); } private static int getConnectRequestLength(String host, Map headers) { @@ -903,26 +988,53 @@ public class ProxyImplTest { } } - private static String getProxyChallenge(boolean includeBasic, boolean includeDigest) { - final String newLine = "\n"; - StringBuilder builder = new StringBuilder("HTTP/1.1 407 Proxy Authentication Required"); - builder.append(newLine); - builder.append("Date: Sun, 05 May 2019 07:28:00 GMT"); - builder.append(newLine); - if (includeBasic) { - builder.append(String.join(" ", PROXY_AUTHENTICATE_HEADER, BASIC)); - builder.append(newLine); + private void setInputBuffer(ProxyImpl proxyImpl, String value) { + final String inputBufferName = "inputBuffer"; + try { + Field inputBuffer = ProxyImpl.class.getDeclaredField(inputBufferName); + inputBuffer.setAccessible(true); + + ByteBuffer buffer = (ByteBuffer) inputBuffer.get(proxyImpl); + buffer.put(value.getBytes()); + } catch (NoSuchFieldException e) { + if (logger.isErrorEnabled()) { + logger.error("Could not locate field '{}' on ProxyImpl class. Exception: {}", inputBufferName, e); + } + } catch (IllegalAccessException e) { + if (logger.isErrorEnabled()) { + logger.error("Could not fetch byte buffer from object.", e); + } } - - if (includeDigest) { - builder.append(String.format("%s %s realm=\"%s\", nonce=\"A randomly set nonce.\", qop=\"auth\", stale=false", - PROXY_AUTHENTICATE_HEADER, DIGEST, PROXY)); - builder.append(newLine); - } - - builder.append(newLine); - - return builder.toString(); } + + private String getProxyResponse(String[] statusLine, List authentications) { + final Map> headers = new HashMap<>(); + + if (!authentications.isEmpty()) { + headers.put(PROXY_AUTHENTICATE, authentications); + } + + return TestUtils.createProxyResponse(statusLine, headers); + } + + private List getProxyResponseFrames(String[] statusLine, List authentications, String body) { + final Map> headers = new HashMap<>(); + + if (!authentications.isEmpty()) { + headers.put(PROXY_AUTHENTICATE, authentications); + } + + String response = TestUtils.createProxyResponse(statusLine, headers, body); + + //Split response into frames base on buffer in characters size + int characters = BUFFER_SIZE / 2; + List frames = new ArrayList<>(); + for (int i = 0; i < response.length(); i += characters) { + frames.add(response.substring(i, Math.min(i + characters, response.length()))); + } + return frames; + } + + } diff --git a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyResponseImplTest.java b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyResponseImplTest.java new file mode 100644 index 0000000..4d22081 --- /dev/null +++ b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/ProxyResponseImplTest.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.azure.proton.transport.proxy.impl; + +import com.microsoft.azure.proton.transport.proxy.HttpStatusLine; +import com.microsoft.azure.proton.transport.proxy.ProxyResponse; +import org.junit.Assert; +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.microsoft.azure.proton.transport.proxy.impl.StringUtils.NEW_LINE; + +public class ProxyResponseImplTest { + /** + * Verifies that it successfully parses a valid HTTP response. + */ + @Test + public void validResponse() { + // Arrange + final String[] statusLine = new String[]{"HTTP/1.1", "200", "Connection Established"}; + final Map> headers = new HashMap<>(); + headers.put("FiddlerGateway", Collections.singletonList("Direct")); + headers.put("StartTime", Collections.singletonList("13:08:21.574")); + headers.put("Connection", Collections.singletonList("close")); + + final String response = TestUtils.createProxyResponse(statusLine, headers); + final ByteBuffer contents = TestUtils.ENCODING.encode(response); + + // Act + final ProxyResponse actual = ProxyResponseImpl.create(contents); + + // Assert + Assert.assertNotNull(actual); + + final HttpStatusLine status = actual.getStatus(); + Assert.assertEquals("1.1", status.getProtocolVersion()); + Assert.assertEquals(statusLine[2], status.getReason()); + Assert.assertEquals(Integer.parseInt(statusLine[1]), status.getStatusCode()); + + Assert.assertFalse(actual.isMissingContent()); + + final Map> actualHeaders = actual.getHeaders(); + Assert.assertEquals(headers.size(), actualHeaders.size()); + + headers.forEach((key, value) -> { + final List actualValue = actualHeaders.get(key); + + Assert.assertTrue(actualHeaders.containsKey(key)); + Assert.assertNotNull(actualValue); + Assert.assertEquals(1, actualValue.size()); + Assert.assertEquals(value.get(0), actualValue.get(0)); + }); + } + + /** + * Verifies that an exception is thrown when buffer is empty + */ + @Test + public void invalidBuffer() { + // Arrange + final ByteBuffer contents = ByteBuffer.allocate(0); + + // Act & Assert + Assert.assertThrows(IllegalArgumentException.class, () -> ProxyResponseImpl.create(contents)); + + } + + /** + * Verifies that an exception is thrown when the header is invalid. + */ + @Test + public void invalidHeader() { + // Arrange + final String[] statusLine = new String[]{"HTTP/1.1", "abc", "Connection Established"}; + final Map> headers = new HashMap<>(); + headers.put("FiddlerGateway", Collections.singletonList("Direct")); + headers.put("StartTime", Collections.singletonList("13:08:21.574")); + headers.put("Connection", Collections.singletonList("close")); + + final String response = TestUtils.createProxyResponse(statusLine, headers); + final ByteBuffer contents = TestUtils.ENCODING.encode(response); + + // Act & Assert + IllegalArgumentException thrown = Assert.assertThrows(IllegalArgumentException.class, + () -> ProxyResponseImpl.create(contents)); + Assert.assertEquals(NumberFormatException.class, thrown.getCause().getClass()); + + } + + /** + * Verifies that we can parse an empty response. + */ + @Test + public void emptyResponse() { + // Arrange + final String emptyResponse = NEW_LINE + NEW_LINE; + final ByteBuffer buffer = ByteBuffer.allocate(1024); + buffer.put(emptyResponse.getBytes(TestUtils.ENCODING)); + buffer.flip(); + + // Act + final ProxyResponse response = ProxyResponseImpl.create(buffer); + + // Assert + Assert.assertNotNull(response); + Assert.assertNull(response.getStatus()); + Assert.assertFalse(response.isMissingContent()); + Assert.assertNotNull(response.getHeaders()); + Assert.assertEquals(0, response.getHeaders().size()); + Assert.assertEquals(0, response.getContents().position()); + } +} diff --git a/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/TestUtils.java b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/TestUtils.java new file mode 100644 index 0000000..45384be --- /dev/null +++ b/src/test/java/com/microsoft/azure/proton/transport/proxy/impl/TestUtils.java @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.azure.proton.transport.proxy.impl; + +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static com.microsoft.azure.proton.transport.proxy.impl.StringUtils.NEW_LINE; + +final class TestUtils { + /** + * Encoding for the HTTP proxy response. + */ + static final Charset ENCODING = StandardCharsets.UTF_8; + + private static final String CONTENT_TYPE = "Content-Type"; + private static final String CONTENT_TYPE_TEXT = "text/plain"; + private static final String CONTENT_LENGTH = "Content-Length"; + private static final String HEADER_FORMAT = "%s: %s" + NEW_LINE; + + private TestUtils() { + } + + /** + * Creates a proxy HTTP response and returns it as a string. + * + * @param statusLine HTTP status line to create the proxy response with. + * @param headers A set of headers to add to the proxy response. + * @return A string representing the contents of the HTTP response. + */ + static String createProxyResponse(String[] statusLine, Map> headers) { + return createProxyResponse(statusLine, headers, null); + } + + /** + * Creates a proxy HTTP response and returns it as a string. If there is content, {@link #CONTENT_LENGTH} and + * {@link #CONTENT_TYPE} headers are added to the {@code headers} parameter. + * + * @param statusLine HTTP status line to create the proxy response with. + * @param headers A set of headers to add to the proxy response. + * @param body Optional HTTP content body. + * @return A string representing the contents of the HTTP response. + */ + static String createProxyResponse(String[] statusLine, Map> headers, String body) { + final ByteBuffer encoded; + if (body != null) { + //Add empty line to end body + body += NEW_LINE; + + encoded = ENCODING.encode(body); + final int size = encoded.remaining(); + + headers.put(CONTENT_TYPE, Collections.singletonList(CONTENT_TYPE_TEXT)); + headers.put(CONTENT_LENGTH, Collections.singletonList(Integer.toString(size))); + } + + final StringBuilder formattedHeaders = headers.entrySet() + .stream() + .collect(StringBuilder::new, + (builder, entry) -> entry.getValue() + .forEach(value -> builder.append(String.format(HEADER_FORMAT, entry.getKey(), value))), + StringBuilder::append); + + String response = String.join(NEW_LINE, + String.join(" ", statusLine), + formattedHeaders.toString(), + NEW_LINE); // The empty new line that ends the HTTP headers. + + if (body != null) { + response += body; + } + + return response; + } + +}