Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Protocol conformance
- [RFC 2817](https://datatracker.ietf.org/doc/html/rfc2817) - Upgrading to TLS Within HTTP/1.1
- [RFC 9218](https://datatracker.ietf.org/doc/html/rfc9218) - Extensible Prioritization Scheme for HTTP
- [RFC 7804](https://datatracker.ietf.org/doc/html/rfc7804) - Salted Challenge Response HTTP Authentication Mechanism
- [RFC 10008](https://datatracker.ietf.org/doc/html/rfc10008) - The HTTP QUERY Method

Licensing
---------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.http.impl.BasicEntityDetails;
import org.apache.hc.core5.http.nio.AsyncDataConsumer;
import org.apache.hc.core5.http.nio.AsyncEntityProducer;
import org.apache.hc.core5.http.nio.CapacityChannel;
import org.apache.hc.core5.http.nio.DataStreamChannel;
import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityProducer;
import org.apache.hc.core5.http.nio.entity.StringAsyncEntityProducer;
import org.apache.hc.core5.net.URIAuthority;
Expand Down Expand Up @@ -412,12 +414,78 @@ public void cancelled() {
}

SimpleHttpRequest prepareRequest(final HttpRequest request,
final AsyncEntityProducer entityProducer) {
// To be revised when implementing QUERY support
if (entityProducer != null) {
return null;
final AsyncEntityProducer entityProducer) throws IOException {
if (entityProducer == null) {
return SimpleRequestBuilder.copy(request).build();
}
// QUERY content must be read in full in order to determine the cache key
if (Method.QUERY.isSame(request.getMethod())
&& entityProducer.isRepeatable()
&& entityProducer.getContentEncoding() == null) {
final byte[] content = drainEntityProducer(entityProducer);
if (content != null) {
return SimpleRequestBuilder.copy(request)
.setBody(content, ContentType.parseLenient(entityProducer.getContentType()))
.build();
}
}
return null;
}

/**
* Drains the given repeatable entity producer into a byte array without an I/O reactor.
* Returns {@code null} if the producer fails to make progress, in which case it is reset
* by {@link AsyncEntityProducer#releaseResources()} and can still be used to execute
* the request directly.
*/
private static byte[] drainEntityProducer(final AsyncEntityProducer entityProducer) throws IOException {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hunghhdev When draining the entity producer we should enforce a total cap on body length in order to avoid some "security professional" reporting it as a potential DoS vulnerability. I imagine requests with content body larger than 25K should be considered non-cacheable

final ByteArrayBuffer buf = new ByteArrayBuffer(1024);
final AtomicBoolean endStream = new AtomicBoolean();
final DataStreamChannel channel = new DataStreamChannel() {

@Override
public void requestOutput() {
}

@Override
public int write(final ByteBuffer src) {
final int len = src.remaining();
if (src.hasArray()) {
buf.append(src.array(), src.arrayOffset() + src.position(), len);
src.position(src.limit());
} else {
final byte[] tmp = new byte[len];
src.get(tmp);
buf.append(tmp, 0, len);
}
return len;
}

@Override
public void endStream() {
endStream.set(true);
}

@Override
public void endStream(final List<? extends Header> trailers) {
endStream.set(true);
}

};
try {
while (!endStream.get()) {
final int before = buf.length();
entityProducer.produce(channel);
if (!endStream.get() && buf.length() == before) {
// The producer is not self-contained and cannot be drained
// without an I/O reactor
return null;
}
}
} finally {
entityProducer.releaseResources();
}
return SimpleRequestBuilder.copy(request).build();
return buf.toByteArray();
}

void callChain(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
Expand All @@ -40,17 +42,22 @@
import java.util.function.Consumer;
import java.util.stream.StreamSupport;

import org.apache.hc.client5.http.async.methods.SimpleBody;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.cache.HttpCacheEntry;
import org.apache.hc.client5.http.utils.Hex;
import org.apache.hc.core5.annotation.Contract;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.annotation.ThreadingBehavior;
import org.apache.hc.core5.function.Resolver;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HeaderElement;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.MessageHeaders;
import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.message.BasicHeaderElementIterator;
import org.apache.hc.core5.http.message.BasicHeaderValueFormatter;
Expand Down Expand Up @@ -131,18 +138,50 @@ public String generateKey(final URI requestUri) {
/**
* Computes a root key for the given {@link HttpHost} and {@link HttpRequest}
* that can be used as a unique identifier for cached resources.
* <p>
* For {@literal QUERY} requests with an enclosed body the key also incorporates
* a digest of the request content and its content type, as required by the HTTP
* QUERY method specification (RFC 10008).
*
* @param host The host for this request
* @param request the {@link HttpRequest}
* @return cache key
*/
public String generateKey(final HttpHost host, final HttpRequest request) {
final String s = CacheSupport.requestUriRaw(host, request);
String rootKey;
try {
return generateKey(new URI(s));
rootKey = generateKey(new URI(s));
} catch (final URISyntaxException ex) {
return s;
rootKey = s;
}
if (request instanceof SimpleHttpRequest && Method.QUERY.isSame(request.getMethod())) {
final SimpleBody body = ((SimpleHttpRequest) request).getBody();
if (body != null) {
return "{QUERY:" + generateContentDigest(body) + "}" + rootKey;
}
}
return rootKey;
}

/**
* Computes a digest of request content and its content type suitable
* for cache key generation.
*/
static String generateContentDigest(final SimpleBody body) {
final MessageDigest digester;
try {
digester = MessageDigest.getInstance("SHA-256");
} catch (final NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 message digest is not supported", ex);
}
final ContentType contentType = body.getContentType();
if (contentType != null) {
digester.update(contentType.toString().getBytes(StandardCharsets.UTF_8));
}
digester.update((byte) 0);
digester.update(body.getBodyBytes());
return Hex.encodeHexString(digester.digest());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public boolean canBeServedFromCache(final RequestCacheControl cacheControl, fina
return false;
}

if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method)) {
if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method) && !Method.QUERY.isSame(method)) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} request cannot be served from cache", method);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.HttpVersion;
import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
Expand Down Expand Up @@ -221,12 +222,21 @@ private static ClassicHttpResponse convert(final SimpleHttpResponse cacheRespons
return response;
}

SimpleHttpRequest prepareRequest(final ClassicHttpRequest request) {
// To be revised when implementing QUERY support
if (request.getEntity() != null) {
return null;
SimpleHttpRequest prepareRequest(final ClassicHttpRequest request) throws IOException {
final HttpEntity entity = request.getEntity();
if (entity == null) {
return SimpleRequestBuilder.copy(request).build();
}
// QUERY content must be read in full in order to determine the cache key
if (Method.QUERY.isSame(request.getMethod()) && entity.getContentEncoding() == null) {
final byte[] content = EntityUtils.toByteArray(entity);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hunghhdev Likewise, we should avoid buffering of content body larger than 25K. That also makes it necessary for the content entity to be repeatable.

final SimpleRequestBuilder builder = SimpleRequestBuilder.copy(request);
if (content != null) {
builder.setBody(content, ContentType.parseLenient(entity.getContentType()));
}
return builder.build();
}
return SimpleRequestBuilder.copy(request).build();
return null;
}

ClassicHttpResponse callBackend(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ public boolean isResponseCacheable(final ResponseCacheControl cacheControl, fina
return false;
}

// Presently only GET and HEAD methods are supported
// Presently only GET, HEAD and QUERY methods are supported
final String httpMethod = request.getMethod();
if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod)) {
if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod) && !Method.QUERY.isSame(httpMethod)) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} method response is not cacheable", httpMethod);
}
Expand Down
Loading
Loading