From 08cb8be22f89e22458e8c9b5b400e1508770a7a3 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Mon, 7 Sep 2026 13:37:38 +0200 Subject: [PATCH] RFC 8336: the ORIGIN HTTP/2 frame Add the ORIGIN frame codec, per-connection Origin Set and client authority enforcement, gated by H2Config. --- .../hc/core5/http2/config/H2Config.java | 67 ++++- .../hc/core5/http2/frame/FrameFactory.java | 12 + .../hc/core5/http2/frame/FrameType.java | 5 +- .../impl/nio/AbstractH2StreamMultiplexer.java | 30 +- .../http2/impl/nio/ClientH2StreamHandler.java | 25 +- .../impl/nio/ClientH2StreamMultiplexer.java | 111 ++++++- .../impl/nio/ClientPushH2StreamHandler.java | 26 +- .../http2/impl/nio/H2OriginFrameCodec.java | 278 ++++++++++++++++++ .../impl/nio/H2OriginMismatchException.java | 39 +++ .../hc/core5/http2/impl/nio/H2OriginSet.java | 126 ++++++++ .../hc/core5/http2/impl/nio/H2Stream.java | 6 +- .../impl/nio/ServerH2StreamMultiplexer.java | 49 ++- .../nio/ServerH2StreamMultiplexerFactory.java | 25 +- .../impl/nio/bootstrap/H2ServerBootstrap.java | 28 +- .../hc/core5/http2/config/H2ConfigTest.java | 14 +- .../examples/H2OriginFrameServerExample.java | 117 ++++++++ .../http2/frame/TestDefaultFrameFactory.java | 13 + .../nio/TestClientH2OriginEnforcement.java | 154 ++++++++++ .../impl/nio/TestClientH2OriginFrame.java | 198 +++++++++++++ .../impl/nio/TestH2OriginFrameCodec.java | 146 +++++++++ .../core5/http2/impl/nio/TestH2OriginSet.java | 93 ++++++ .../impl/nio/TestH2StreamOriginMismatch.java | 55 ++++ .../impl/nio/TestServerH2OriginFrame.java | 113 +++++++ 23 files changed, 1712 insertions(+), 18 deletions(-) create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginFrameCodec.java create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginMismatchException.java create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginSet.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/examples/H2OriginFrameServerExample.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2OriginEnforcement.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2OriginFrame.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2OriginFrameCodec.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2OriginSet.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2StreamOriginMismatch.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestServerH2OriginFrame.java diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Config.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Config.java index 89b538860d..bb8284e86a 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Config.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Config.java @@ -51,10 +51,13 @@ public class H2Config { private final int maxHeaderListSize; private final boolean compressionEnabled; private final int maxContinuations; + private final boolean originFrameEnabled; + private final int maxOriginSetSize; H2Config(final int headerTableSize, final boolean pushEnabled, final int maxConcurrentStreams, final int initialWindowSize, final int maxFrameSize, final int maxHeaderListSize, - final boolean compressionEnabled, final int maxContinuations) { + final boolean compressionEnabled, final int maxContinuations, + final boolean originFrameEnabled, final int maxOriginSetSize) { super(); this.headerTableSize = headerTableSize; this.pushEnabled = pushEnabled; @@ -64,6 +67,8 @@ public class H2Config { this.maxHeaderListSize = maxHeaderListSize; this.compressionEnabled = compressionEnabled; this.maxContinuations = maxContinuations; + this.originFrameEnabled = originFrameEnabled; + this.maxOriginSetSize = maxOriginSetSize; } public int getHeaderTableSize() { @@ -98,6 +103,27 @@ public int getMaxContinuations() { return maxContinuations; } + /** + * Tests whether ORIGIN frames are enabled. Proxy clients that receive + * HTTP/2 frames directly from a proxy must disable this option. + * + * @return {@code true} if ORIGIN frames are enabled. + * @since 5.5 + */ + public boolean isOriginFrameEnabled() { + return originFrameEnabled; + } + + /** + * Returns the maximum number of origins retained for a connection. A value of + * {@code 0} means unlimited. + * + * @since 5.5 + */ + public int getMaxOriginSetSize() { + return maxOriginSetSize; + } + @Override public String toString() { final StringBuilder builder = new StringBuilder(); @@ -109,6 +135,8 @@ public String toString() { .append(", maxHeaderListSize=").append(this.maxHeaderListSize) .append(", compressionEnabled=").append(this.compressionEnabled) .append(", maxContinuations=").append(this.maxContinuations) + .append(", originFrameEnabled=").append(this.originFrameEnabled) + .append(", maxOriginSetSize=").append(this.maxOriginSetSize) .append("]"); return builder.toString(); } @@ -142,7 +170,10 @@ public static H2Config.Builder copy(final H2Config config) { .setInitialWindowSize(config.getInitialWindowSize()) .setMaxFrameSize(config.getMaxFrameSize()) .setMaxHeaderListSize(config.getMaxHeaderListSize()) - .setCompressionEnabled(config.isCompressionEnabled()); + .setCompressionEnabled(config.isCompressionEnabled()) + .setMaxContinuations(config.getMaxContinuations()) + .setOriginFrameEnabled(config.isOriginFrameEnabled()) + .setMaxOriginSetSize(config.getMaxOriginSetSize()); } public static class Builder { @@ -155,6 +186,8 @@ public static class Builder { private int maxHeaderListSize; private boolean compressionEnabled; private int maxContinuations; + private boolean originFrameEnabled; + private int maxOriginSetSize; Builder() { this.headerTableSize = INIT_HEADER_TABLE_SIZE * 2; @@ -165,6 +198,8 @@ public static class Builder { this.maxHeaderListSize = FrameConsts.MAX_FRAME_SIZE; this.compressionEnabled = true; this.maxContinuations = 100; + this.originFrameEnabled = true; + this.maxOriginSetSize = 1000; } public Builder setHeaderTableSize(final int headerTableSize) { @@ -216,6 +251,30 @@ public Builder setMaxContinuations(final int maxContinuations) { return this; } + /** + * Enables or disables ORIGIN frame processing. This should be set + * to {@code false} by clients that receive HTTP/2 frames directly from a + * proxy, which must ignore any ORIGIN frames received from it. + * + * @since 5.5 + */ + public Builder setOriginFrameEnabled(final boolean originFrameEnabled) { + this.originFrameEnabled = originFrameEnabled; + return this; + } + + /** + * Sets the maximum number of origins retained for one connection. A value + * of {@code 0} disables the limit. Exceeding a positive limit terminates + * the connection with {@code ENHANCE_YOUR_CALM}. + * + * @since 5.5 + */ + public Builder setMaxOriginSetSize(final int maxOriginSetSize) { + this.maxOriginSetSize = Args.notNegative(maxOriginSetSize, "Max Origin Set size"); + return this; + } + public H2Config build() { return new H2Config( headerTableSize, @@ -225,7 +284,9 @@ public H2Config build() { maxFrameSize, maxHeaderListSize, compressionEnabled, - maxContinuations); + maxContinuations, + originFrameEnabled, + maxOriginSetSize); } } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameFactory.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameFactory.java index 0efe74e732..ef5254f915 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameFactory.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameFactory.java @@ -116,4 +116,16 @@ public RawFrame createPriorityUpdate(final ByteBuffer payload) { return new RawFrame(FrameType.PRIORITY_UPDATE.getValue(), 0, 0, payload); } + /** + * Creates an ORIGIN frame. + * + * @param payload the encoded sequence of Origin-Entry values, or {@code null} + * for an empty Origin Set advertisement. + * @return the ORIGIN frame. + * @since 5.5 + */ + public RawFrame createOrigin(final ByteBuffer payload) { + return new RawFrame(FrameType.ORIGIN.getValue(), 0, 0, payload); + } + } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameType.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameType.java index 2253e24ca6..4d61699968 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameType.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameType.java @@ -43,6 +43,7 @@ public enum FrameType { GOAWAY(0x07), WINDOW_UPDATE(0x08), CONTINUATION(0x09), + ORIGIN(0x0c), PRIORITY_UPDATE(0x10); // 16 final int value; @@ -73,7 +74,7 @@ public static FrameType valueOf(final int value) { if (value < 0 || value >= LOOKUP_TABLE.length) { return null; } - return LOOKUP_TABLE[value]; // may be null for gaps (e.g., 0x0A..0x0F) + return LOOKUP_TABLE[value]; // may be null for gaps (e.g., 0x0A) } public static String toString(final int value) { @@ -88,4 +89,4 @@ public static String toString(final int value) { public boolean same(final int rawType) { return this.value == rawType; } -} \ No newline at end of file +} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java index cf086be8ba..9ca6cf5a56 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java @@ -232,6 +232,18 @@ HttpProcessor getHttpProcessor() { return httpProcessor; } + final H2Config getLocalConfig() { + return localConfig; + } + + final FrameFactory getFrameFactory() { + return frameFactory; + } + + final int getMaxFramePayloadSize() { + return Math.min(remoteConfig.getMaxFrameSize(), outputBuffer.getMaxFramePayloadSize()); + } + void submitCommand(final Command command) { ioSession.enqueue(command, Command.Priority.NORMAL); } @@ -261,6 +273,14 @@ abstract H2StreamHandler outgoingPushPromise(H2StreamChannel channel, abstract boolean allowGracefulAbort(H2Stream stream); + /** Called after the local SETTINGS frame has been queued. */ + void onConnectComplete() throws HttpException, IOException { + } + + /** Handles an ORIGIN frame. The server-side default is to ignore it. */ + void consumeOriginFrame(final RawFrame frame) throws HttpException, IOException { + } + private int updateWindow(final AtomicInteger window, final int delta) throws ArithmeticException { for (;;) { final int current = window.get(); @@ -324,6 +344,10 @@ private void commitFrame(final RawFrame frame) throws IOException { updateLastActivity(); } + final void commitConnectionFrame(final RawFrame frame) throws IOException { + commitFrame(frame); + } + private void commitHeaders( final int streamId, final List headers, final boolean endStream) throws IOException { if (streamListener != null) { @@ -462,6 +486,7 @@ public final void onConnect() throws HttpException, IOException { commitFrame(settingsFrame); localSettingState = SettingsHandshake.TRANSMITTED; + onConnectComplete(); maximizeWindow(0, connInputWindow); if (streamListener != null) { @@ -1015,6 +1040,9 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio break; case PRIORITY: break; + case ORIGIN: + consumeOriginFrame(frame); + break; case PUSH_PROMISE: { acceptPushFrame(); if (streamId == 0) { @@ -1768,4 +1796,4 @@ private void validateStreamTimeouts() throws IOException { private void updateLastActivity() { this.lastActivityNanos = System.nanoTime(); } -} \ No newline at end of file +} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamHandler.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamHandler.java index 7871dd4cb7..11401a5cc3 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamHandler.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamHandler.java @@ -37,6 +37,7 @@ import org.apache.hc.core5.http.HeaderElements; import org.apache.hc.core5.http.HttpException; 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.HttpResponse; import org.apache.hc.core5.http.HttpStatus; @@ -73,10 +74,12 @@ class ClientH2StreamHandler implements H2StreamHandler { private final AtomicBoolean requestCommitted; private final AtomicBoolean failed; private final AtomicBoolean done; + private final H2OriginSet originSet; private volatile String method = null; private volatile long declaredContentLen = -1; private volatile long actualContentLen = 0; + private volatile HttpHost requestOrigin; ClientH2StreamHandler( final H2StreamChannel outputChannel, @@ -85,6 +88,17 @@ class ClientH2StreamHandler implements H2StreamHandler { final AsyncClientExchangeHandler exchangeHandler, final HandlerFactory pushHandlerFactory, final HttpCoreContext context) { + this(outputChannel, httpProcessor, connMetrics, exchangeHandler, pushHandlerFactory, context, null); + } + + ClientH2StreamHandler( + final H2StreamChannel outputChannel, + final HttpProcessor httpProcessor, + final BasicHttpConnectionMetrics connMetrics, + final AsyncClientExchangeHandler exchangeHandler, + final HandlerFactory pushHandlerFactory, + final HttpCoreContext context, + final H2OriginSet originSet) { this.outputChannel = outputChannel; this.dataChannel = new DataStreamChannel() { @@ -116,6 +130,7 @@ public void endStream() throws IOException { this.exchangeHandler = exchangeHandler; this.pushHandlerFactory = pushHandlerFactory; this.context = context; + this.originSet = originSet; this.requestCommitted = new AtomicBoolean(); this.failed = new AtomicBoolean(); this.done = new AtomicBoolean(); @@ -147,6 +162,11 @@ private void commitRequest(final HttpRequest request, final EntityDetails entity httpProcessor.process(request, entityDetails, context); + requestOrigin = H2OriginFrameCodec.fromRequest(request); + if (originSet != null) { + originSet.ensureAllowed(requestOrigin); + } + method = request.getMethod(); final List
headers = DefaultH2RequestConverter.INSTANCE.convert(request); @@ -215,6 +235,10 @@ public void consumeHeader(final List
headers, final boolean endStream) t return; } + if (status == HttpStatus.SC_MISDIRECTED_REQUEST && originSet != null) { + originSet.remove(requestOrigin); + } + if (!Method.HEAD.isSame(method) && MessageSupport.canResponseHaveBody(method, response)) { declaredContentLen = MessageSupport.getContentLength(response); if (endStream) { @@ -303,4 +327,3 @@ public String toString() { } } - diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamMultiplexer.java index 83b7b4d629..4ed3770981 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamMultiplexer.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamMultiplexer.java @@ -27,8 +27,18 @@ package org.apache.hc.core5.http2.impl.nio; import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; +import java.util.Set; + +import javax.net.ssl.ExtendedSSLSession; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLSession; import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.config.CharCodingConfig; import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; import org.apache.hc.core5.http.nio.AsyncPushConsumer; @@ -44,7 +54,9 @@ import org.apache.hc.core5.http2.config.H2Setting; import org.apache.hc.core5.http2.frame.DefaultFrameFactory; import org.apache.hc.core5.http2.frame.FrameFactory; +import org.apache.hc.core5.http2.frame.RawFrame; import org.apache.hc.core5.http2.frame.StreamIdGenerator; +import org.apache.hc.core5.net.NamedEndpoint; import org.apache.hc.core5.reactor.ProtocolIOSession; import org.apache.hc.core5.util.Timeout; @@ -59,6 +71,7 @@ public class ClientH2StreamMultiplexer extends AbstractH2StreamMultiplexer { private final HandlerFactory pushHandlerFactory; + private final H2OriginSet originSet; /** * @since 5.5 @@ -76,6 +89,7 @@ public ClientH2StreamMultiplexer( super(ioSession, frameFactory, StreamIdGenerator.ODD, httpProcessor, charCodingConfig, h2Config, streamListener, validateAfterInactivity, pingAckTimeout); this.pushHandlerFactory = pushHandlerFactory; + this.originSet = createOriginSet(ioSession, h2Config); } public ClientH2StreamMultiplexer( @@ -90,6 +104,7 @@ public ClientH2StreamMultiplexer( super(ioSession, frameFactory, StreamIdGenerator.ODD, httpProcessor, charCodingConfig, h2Config, streamListener, validateAfterInactivity); this.pushHandlerFactory = pushHandlerFactory; + this.originSet = createOriginSet(ioSession, h2Config); } public ClientH2StreamMultiplexer( @@ -159,6 +174,47 @@ void acceptPushRequest() throws H2ConnectionException { throw new H2ConnectionException(H2Error.INTERNAL_ERROR, "Illegal attempt to push a response"); } + @Override + void consumeOriginFrame(final RawFrame frame) throws H2ConnectionException { + if (!getLocalConfig().isOriginFrameEnabled() + || getSSLSession() == null + || frame.getStreamId() != 0 + || (frame.getFlags() & 0x0f) != 0) { + return; + } + originSet.update(H2OriginFrameCodec.decode(frame.getPayload())); + } + + /** + * Tests whether an ORIGIN frame has initialized this connection's + * Origin Set. + * + * @since 5.5 + */ + public boolean isOriginSetInitialized() { + return originSet.isInitialized(); + } + + /** + * Returns an immutable snapshot of this connection's Origin Set. + * + * @since 5.5 + */ + public Set getOriginSet() { + return originSet.snapshot(); + } + + /** + * Tests whether a request to the given origin may use this connection based + * on its Origin Set. TLS certificate checks remain the caller's + * responsibility when selecting a connection for another origin. + * + * @since 5.5 + */ + public boolean isOriginAllowed(final HttpHost origin) { + return originSet.isAllowed(origin); + } + @Override H2StreamHandler outgoingRequest( final H2StreamChannel channel, @@ -170,7 +226,7 @@ H2StreamHandler outgoingRequest( coreContext.setEndpointDetails(getEndpointDetails()); return new ClientH2StreamHandler(channel, getHttpProcessor(), getConnMetrics(), exchangeHandler, pushHandlerFactory != null ? pushHandlerFactory : this.pushHandlerFactory, - coreContext); + coreContext, originSet); } @Override @@ -191,7 +247,7 @@ H2StreamHandler incomingPushPromise(final H2StreamChannel channel, context.setEndpointDetails(getEndpointDetails()); return new ClientPushH2StreamHandler(channel, getHttpProcessor(), getConnMetrics(), pushHandlerFactory != null ? pushHandlerFactory : this.pushHandlerFactory, - context); + context, originSet); } @Override @@ -208,5 +264,54 @@ public String toString() { return buf.toString(); } -} + private static H2OriginSet createOriginSet(final ProtocolIOSession ioSession, final H2Config config) { + final H2Config actualConfig = config != null ? config : H2Config.DEFAULT; + return new H2OriginSet(determineInitialOrigin(ioSession), actualConfig.getMaxOriginSetSize()); + } + private static HttpHost determineInitialOrigin(final ProtocolIOSession ioSession) { + final SSLSession sslSession = ioSession.getTlsDetails() != null + ? ioSession.getTlsDetails().getSSLSession() + : null; + final NamedEndpoint initialEndpoint = ioSession.getInitialEndpoint(); + String hostName = getSniHostName(sslSession); + if (hostName == null && initialEndpoint != null) { + hostName = initialEndpoint.getHostName(); + } + if (hostName == null && sslSession != null) { + hostName = sslSession.getPeerHost(); + } + int port = sslSession != null ? sslSession.getPeerPort() : -1; + final SocketAddress remoteAddress = ioSession.getRemoteAddress(); + if (remoteAddress instanceof InetSocketAddress) { + final InetSocketAddress inetAddress = (InetSocketAddress) remoteAddress; + if (hostName == null) { + hostName = inetAddress.getAddress() != null + ? inetAddress.getAddress().getHostAddress() + : inetAddress.getHostString(); + } + if (port <= 0) { + port = inetAddress.getPort(); + } + } + if (port < 0 && initialEndpoint != null) { + port = initialEndpoint.getPort(); + } + return hostName != null && port >= 0 ? new HttpHost("https", hostName, port) : null; + } + + private static String getSniHostName(final SSLSession sslSession) { + if (sslSession instanceof ExtendedSSLSession) { + final List serverNames = ((ExtendedSSLSession) sslSession).getRequestedServerNames(); + if (serverNames != null) { + for (final SNIServerName serverName : serverNames) { + if (serverName instanceof SNIHostName) { + return ((SNIHostName) serverName).getAsciiName(); + } + } + } + } + return null; + } + +} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientPushH2StreamHandler.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientPushH2StreamHandler.java index 3b32536ca5..71624659ec 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientPushH2StreamHandler.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientPushH2StreamHandler.java @@ -34,8 +34,10 @@ import org.apache.hc.core5.http.EntityDetails; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; 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.HttpVersion; import org.apache.hc.core5.http.ProtocolException; import org.apache.hc.core5.http.impl.BasicHttpConnectionMetrics; @@ -62,6 +64,7 @@ class ClientPushH2StreamHandler implements H2StreamHandler { private final HttpCoreContext context; private final AtomicBoolean failed; private final AtomicBoolean done; + private final H2OriginSet originSet; private volatile HttpRequest request; private volatile AsyncPushConsumer exchangeHandler; @@ -70,6 +73,7 @@ class ClientPushH2StreamHandler implements H2StreamHandler { private volatile long declaredContentLen = -1; private volatile long actualContentLen = 0; + private volatile HttpHost requestOrigin; ClientPushH2StreamHandler( final H2StreamChannel outputChannel, @@ -77,11 +81,22 @@ class ClientPushH2StreamHandler implements H2StreamHandler { final BasicHttpConnectionMetrics connMetrics, final HandlerFactory pushHandlerFactory, final HttpCoreContext context) { + this(outputChannel, httpProcessor, connMetrics, pushHandlerFactory, context, null); + } + + ClientPushH2StreamHandler( + final H2StreamChannel outputChannel, + final HttpProcessor httpProcessor, + final BasicHttpConnectionMetrics connMetrics, + final HandlerFactory pushHandlerFactory, + final HttpCoreContext context, + final H2OriginSet originSet) { this.internalOutputChannel = outputChannel; this.httpProcessor = httpProcessor; this.connMetrics = connMetrics; this.pushHandlerFactory = pushHandlerFactory; this.context = context; + this.originSet = originSet; this.failed = new AtomicBoolean(); this.done = new AtomicBoolean(); this.requestState = MessageState.HEADERS; @@ -107,6 +122,13 @@ public void consumePromise(final List
headers) throws HttpException, IOE if (requestState == MessageState.HEADERS) { request = DefaultH2RequestConverter.INSTANCE.convert(headers); + requestOrigin = H2OriginFrameCodec.fromRequest(request); + if (originSet != null && requestOrigin != null && !originSet.isAllowed(requestOrigin)) { + throw new H2StreamResetException( + H2Error.REFUSED_STREAM, + "Pushed origin " + H2OriginFrameCodec.format(requestOrigin) + + " is not in the connection Origin Set"); + } try { exchangeHandler = pushHandlerFactory != null ? pushHandlerFactory.create(request, context) : null; } catch (final ProtocolException ex) { @@ -136,6 +158,9 @@ public void consumeHeader(final List
headers, final boolean endStream) t Asserts.notNull(exchangeHandler, "Exchange handler"); final HttpResponse response = DefaultH2ResponseConverter.INSTANCE.convert(headers); + if (response.getCode() == HttpStatus.SC_MISDIRECTED_REQUEST && originSet != null) { + originSet.remove(requestOrigin); + } if (MessageSupport.canResponseHaveBody(response)) { declaredContentLen = MessageSupport.getContentLength(response); @@ -233,4 +258,3 @@ public String toString() { } } - diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginFrameCodec.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginFrameCodec.java new file mode 100644 index 0000000000..76cc230c13 --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginFrameCodec.java @@ -0,0 +1,278 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.ProtocolException; +import org.apache.hc.core5.net.URIAuthority; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TextUtils; +import org.apache.hc.core5.util.Tokenizer; + +/** ORIGIN frame wire codec and ASCII origin normalization. */ +final class H2OriginFrameCodec { + + private static final Tokenizer.Delimiter SCHEME_DELIMITER = Tokenizer.delimiters(':'); + private static final Tokenizer.Delimiter AUTHORITY_DELIMITER = Tokenizer.delimiters('/', '?', '#'); + + private H2OriginFrameCodec() { + } + + static HttpHost parse(final CharSequence text) throws URISyntaxException { + Args.notNull(text, "Origin"); + if (text.length() == 0) { + throw new URISyntaxException(text.toString(), "Origin is empty"); + } + for (int i = 0; i < text.length(); i++) { + final char ch = text.charAt(i); + if (ch <= 0x20 || ch >= 0x7f) { + throw new URISyntaxException(text.toString(), "Origin is not visible US-ASCII", i); + } + } + + final Tokenizer.Cursor cursor = new Tokenizer.Cursor(0, text.length()); + final String scheme = Tokenizer.INSTANCE.parseContent(text, cursor, SCHEME_DELIMITER); + if (!isSchemeValid(scheme) || cursor.atEnd() || text.charAt(cursor.getPos()) != ':') { + throw new URISyntaxException(text.toString(), "Invalid scheme", cursor.getPos()); + } + final int authorityStart = cursor.getPos() + 3; + if (authorityStart > text.length() + || text.charAt(cursor.getPos() + 1) != '/' + || text.charAt(cursor.getPos() + 2) != '/') { + throw new URISyntaxException(text.toString(), "Expected hierarchical origin", cursor.getPos()); + } + cursor.updatePos(authorityStart); + final String authorityText = Tokenizer.INSTANCE.parseContent(text, cursor, AUTHORITY_DELIMITER); + if (!cursor.atEnd()) { + throw new URISyntaxException(text.toString(), "Path, query, and fragment are not allowed", cursor.getPos()); + } + if (authorityText.isEmpty()) { + throw new URISyntaxException(text.toString(), "Authority is empty", authorityStart); + } + validatePortDigits(authorityText, text.toString(), authorityStart); + + final URIAuthority authority = URIAuthority.create(authorityText); + if (authority == null + || TextUtils.isBlank(authority.getHostName()) + || authority.getHostName().indexOf('*') >= 0 + || authority.getHostName().indexOf('%') >= 0 + || authority.getUserInfo() != null) { + throw new URISyntaxException(text.toString(), "Invalid origin authority", authorityStart); + } + final int port = resolvePort(scheme, authority.getPort()); + if (port < 0) { + throw new URISyntaxException(text.toString(), "An explicit port is required for this scheme", authorityStart); + } + return new HttpHost(scheme, authority.getHostName().toLowerCase(Locale.ROOT), port); + } + + static HttpHost normalize(final HttpHost origin) { + Args.notNull(origin, "Origin"); + final String scheme = origin.getSchemeName(); + Args.check(isSchemeValid(scheme), "Invalid origin scheme: %s", scheme); + Args.notBlank(origin.getHostName(), "Origin host"); + Args.check(origin.getHostName().indexOf('*') < 0, "Wildcard origins are not supported"); + Args.check(origin.getHostName().indexOf('%') < 0, "Scoped IP literals are not supported in origins"); + final int port = resolvePort(scheme, origin.getPort()); + Args.check(port >= 0, "An explicit port is required for scheme '%s'", scheme); + return new HttpHost(scheme, origin.getHostName().toLowerCase(Locale.ROOT), port); + } + + static List normalize(final Collection origins) { + Args.notNull(origins, "Origins"); + final List result = new ArrayList<>(origins.size()); + for (final HttpHost origin : origins) { + result.add(normalize(origin)); + } + return Collections.unmodifiableList(result); + } + + static HttpHost fromRequest(final HttpRequest request) throws ProtocolException { + final String scheme = request.getScheme(); + final URIAuthority authority = request.getAuthority(); + if (TextUtils.isBlank(scheme) || authority == null) { + return null; + } + if (authority.getUserInfo() != null) { + throw new ProtocolException("Request authority contains user info"); + } + final int port = resolvePort(scheme, authority.getPort()); + if (port < 0) { + throw new ProtocolException("Request origin has no explicit or default port"); + } + return new HttpHost(scheme, authority.getHostName(), port); + } + + static Set decode(final ByteBuffer payload) { + if (payload == null) { + return Collections.emptySet(); + } + final ByteBuffer src = payload.duplicate(); + final Set origins = new LinkedHashSet<>(); + while (src.remaining() >= 2) { + final int length = src.getShort() & 0xffff; + if (length > src.remaining()) { + break; + } + final byte[] bytes = new byte[length]; + src.get(bytes); + boolean ascii = true; + for (final byte b : bytes) { + if ((b & 0x80) != 0) { + ascii = false; + break; + } + } + if (ascii) { + try { + origins.add(parse(new String(bytes, StandardCharsets.US_ASCII))); + } catch (final URISyntaxException | IllegalArgumentException ignore) { + // Invalid ASCII-Origin entries are ignored. + } + } + } + return origins; + } + + static List encode(final Collection origins, final int maxFrameSize) { + Args.notNull(origins, "Origins"); + Args.positive(maxFrameSize, "Maximum frame size"); + final LinkedHashSet values = new LinkedHashSet<>(); + for (final HttpHost origin : origins) { + values.add(format(origin)); + } + if (values.isEmpty()) { + return Collections.singletonList(ByteBuffer.allocate(0)); + } + + final List payloads = new ArrayList<>(); + ByteBuffer payload = ByteBuffer.allocate(maxFrameSize); + for (final String value : values) { + final byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + final int entryLength = 2 + bytes.length; + Args.check(bytes.length <= 0xffff, "Origin is too long"); + Args.check(entryLength <= maxFrameSize, "Origin does not fit into an HTTP/2 frame"); + if (payload.remaining() < entryLength) { + payload.flip(); + payloads.add(payload); + payload = ByteBuffer.allocate(maxFrameSize); + } + payload.putShort((short) bytes.length); + payload.put(bytes); + } + payload.flip(); + payloads.add(payload); + return payloads; + } + + static String format(final HttpHost origin) { + final HttpHost normalized = normalize(origin); + final int port = normalized.getPort(); + final int defaultPort = defaultPort(normalized.getSchemeName()); + final HttpHost serialized = new HttpHost( + normalized.getSchemeName(), + normalized.getHostName(), + port == defaultPort ? -1 : port); + final String value = serialized.toURI(); + try { + parse(value); + } catch (final URISyntaxException ex) { + throw new IllegalArgumentException("Invalid origin: " + value, ex); + } + return value; + } + + private static boolean isSchemeValid(final String scheme) { + if (TextUtils.isBlank(scheme) + || scheme.charAt(0) >= 0x80 + || !Character.isLetter(scheme.charAt(0))) { + return false; + } + for (int i = 1; i < scheme.length(); i++) { + final char ch = scheme.charAt(i); + if (ch >= 0x80 + || !Character.isLetter(ch) && !Character.isDigit(ch) && ch != '+' && ch != '-' && ch != '.') { + return false; + } + } + return true; + } + + private static void validatePortDigits( + final String authority, final String input, final int authorityStart) throws URISyntaxException { + final int colon; + if (authority.charAt(0) == '[') { + final int bracket = authority.indexOf(']'); + if (bracket < 0) { + throw new URISyntaxException(input, "Invalid IPv6 authority", authorityStart); + } + colon = bracket + 1 < authority.length() ? bracket + 1 : -1; + if (colon >= 0 && authority.charAt(colon) != ':') { + throw new URISyntaxException(input, "Invalid IPv6 authority", authorityStart + colon); + } + } else { + colon = authority.lastIndexOf(':'); + } + if (colon >= 0) { + if (colon + 1 >= authority.length()) { + throw new URISyntaxException(input, "Port is empty", authorityStart + colon + 1); + } + for (int i = colon + 1; i < authority.length(); i++) { + if (!Character.isDigit(authority.charAt(i))) { + throw new URISyntaxException(input, "Port is invalid", authorityStart + i); + } + } + } + } + + private static int resolvePort(final String scheme, final int port) { + return port >= 0 ? port : defaultPort(scheme); + } + + private static int defaultPort(final String scheme) { + final String normalized = scheme.toLowerCase(Locale.ROOT); + if ("http".equals(normalized)) { + return 80; + } + if ("https".equals(normalized)) { + return 443; + } + return -1; + } +} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginMismatchException.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginMismatchException.java new file mode 100644 index 0000000000..985bd1368b --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginMismatchException.java @@ -0,0 +1,39 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import org.apache.hc.core5.http.MisdirectedRequestException; + +/** Signals a local request rejected by an initialized Origin Set. */ +final class H2OriginMismatchException extends MisdirectedRequestException { + + private static final long serialVersionUID = 1L; + + H2OriginMismatchException(final String message) { + super(message); + } +} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginSet.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginSet.java new file mode 100644 index 0000000000..f6e92e4e66 --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2OriginSet.java @@ -0,0 +1,126 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.MisdirectedRequestException; +import org.apache.hc.core5.http2.H2ConnectionException; +import org.apache.hc.core5.http2.H2Error; + +/** Thread-safe per-connection Origin Set state. */ +final class H2OriginSet { + + private static final class State { + + private final boolean initialized; + private final Set origins; + + private State(final boolean initialized, final Set origins) { + this.initialized = initialized; + this.origins = origins; + } + } + + private final HttpHost initialOrigin; + private final int maxSize; + private final AtomicReference stateRef; + + H2OriginSet(final HttpHost initialOrigin, final int maxSize) { + this.initialOrigin = initialOrigin != null ? H2OriginFrameCodec.normalize(initialOrigin) : null; + this.maxSize = maxSize; + this.stateRef = new AtomicReference<>(new State(false, Collections.emptySet())); + } + + boolean isInitialized() { + return stateRef.get().initialized; + } + + Set snapshot() { + return stateRef.get().origins; + } + + boolean isAllowed(final HttpHost origin) { + final State state = stateRef.get(); + return !state.initialized || state.origins.contains(H2OriginFrameCodec.normalize(origin)); + } + + void ensureAllowed(final HttpHost origin) throws MisdirectedRequestException { + if (origin != null && !isAllowed(origin)) { + throw new H2OriginMismatchException( + "Origin " + H2OriginFrameCodec.format(origin) + " is not in the connection Origin Set"); + } + } + + void update(final Collection additions) throws H2ConnectionException { + for (;;) { + final State current = stateRef.get(); + final LinkedHashSet origins = new LinkedHashSet<>(); + if (current.initialized) { + origins.addAll(current.origins); + } else if (initialOrigin != null) { + origins.add(initialOrigin); + } + for (final HttpHost origin : additions) { + origins.add(H2OriginFrameCodec.normalize(origin)); + } + if (maxSize > 0 && origins.size() > maxSize) { + throw new H2ConnectionException( + H2Error.ENHANCE_YOUR_CALM, + "Origin Set exceeds the configured limit of " + maxSize); + } + final State updated = new State(true, Collections.unmodifiableSet(origins)); + if (stateRef.compareAndSet(current, updated)) { + return; + } + } + } + + void remove(final HttpHost origin) { + if (origin == null) { + return; + } + final HttpHost normalized = H2OriginFrameCodec.normalize(origin); + for (;;) { + final State current = stateRef.get(); + if (!current.initialized || !current.origins.contains(normalized)) { + return; + } + final LinkedHashSet origins = new LinkedHashSet<>(current.origins); + origins.remove(normalized); + final State updated = new State(true, Collections.unmodifiableSet(origins)); + if (stateRef.compareAndSet(current, updated)) { + return; + } + } + } +} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java index 7bd10613d1..5a7131ec87 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java @@ -220,6 +220,10 @@ boolean isOutputReady() { void produceOutput() throws HttpException, IOException { try { handler.produceOutput(); + } catch (final H2OriginMismatchException ex) { + // No HEADERS have been emitted for this locally rejected request. + // Sending RST_STREAM for an idle stream would itself be a protocol error. + fail(ex); } catch (final ProtocolException ex) { localReset(ex, H2Error.PROTOCOL_ERROR); } @@ -343,4 +347,4 @@ void setPriorityValue(final PriorityValue priorityValue) { this.priorityValue = priorityValue; } -} \ No newline at end of file +} diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ServerH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ServerH2StreamMultiplexer.java index 9a70b3c959..6c48ce6fe9 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ServerH2StreamMultiplexer.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ServerH2StreamMultiplexer.java @@ -28,11 +28,13 @@ import java.io.IOException; import java.nio.ByteBuffer; +import java.util.Collection; import java.util.List; import org.apache.hc.core5.annotation.Internal; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.RequestHeaderFieldsTooLargeException; import org.apache.hc.core5.http.config.CharCodingConfig; import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; @@ -66,7 +68,11 @@ public class ServerH2StreamMultiplexer extends AbstractH2StreamMultiplexer { private final HandlerFactory exchangeHandlerFactory; + private final List configuredOriginSet; + /** + * @since 5.5 + */ public ServerH2StreamMultiplexer( final ProtocolIOSession ioSession, final FrameFactory frameFactory, @@ -74,9 +80,23 @@ public ServerH2StreamMultiplexer( final HandlerFactory exchangeHandlerFactory, final CharCodingConfig charCodingConfig, final H2Config h2Config, - final H2StreamListener streamListener) { + final H2StreamListener streamListener, + final Collection originSet) { super(ioSession, frameFactory, StreamIdGenerator.EVEN, httpProcessor, charCodingConfig, h2Config, streamListener); this.exchangeHandlerFactory = Args.notNull(exchangeHandlerFactory, "Handler factory"); + this.configuredOriginSet = originSet != null ? H2OriginFrameCodec.normalize(originSet) : null; + } + + public ServerH2StreamMultiplexer( + final ProtocolIOSession ioSession, + final FrameFactory frameFactory, + final HttpProcessor httpProcessor, + final HandlerFactory exchangeHandlerFactory, + final CharCodingConfig charCodingConfig, + final H2Config h2Config, + final H2StreamListener streamListener) { + this(ioSession, frameFactory, httpProcessor, exchangeHandlerFactory, charCodingConfig, h2Config, + streamListener, null); } public ServerH2StreamMultiplexer( @@ -121,6 +141,33 @@ void acceptPushFrame() throws H2ConnectionException { throw new H2ConnectionException(H2Error.PROTOCOL_ERROR, "Push not supported"); } + @Override + void onConnectComplete() throws IOException { + if (configuredOriginSet != null) { + sendOriginSet(configuredOriginSet); + } + } + + /** + * Sends an ORIGIN advertisement. Entries are split across frames + * when necessary. An empty collection sends an empty ORIGIN frame, which + * initializes the peer's Origin Set with the connection's initial origin. + * No frame is sent on cleartext HTTP/2 connections. + * + * @param origins origins to advertise. + * @throws IOException in case of an I/O error. + * @since 5.5 + */ + public void sendOriginSet(final Collection origins) throws IOException { + Args.notNull(origins, "Origins"); + if (!getLocalConfig().isOriginFrameEnabled() || getSSLSession() == null) { + return; + } + for (final ByteBuffer payload : H2OriginFrameCodec.encode(origins, getMaxFramePayloadSize())) { + commitConnectionFrame(getFrameFactory().createOrigin(payload)); + } + } + @Override H2StreamHandler incomingRequest(final H2StreamChannel channel) { final HttpCoreContext context = HttpCoreContext.create(); diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ServerH2StreamMultiplexerFactory.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ServerH2StreamMultiplexerFactory.java index 49b3876845..1ad0ba1eae 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ServerH2StreamMultiplexerFactory.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ServerH2StreamMultiplexerFactory.java @@ -27,9 +27,13 @@ package org.apache.hc.core5.http2.impl.nio; +import java.util.Collection; +import java.util.List; + 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.http.HttpHost; import org.apache.hc.core5.http.config.CharCodingConfig; import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; import org.apache.hc.core5.http.nio.HandlerFactory; @@ -55,20 +59,36 @@ public final class ServerH2StreamMultiplexerFactory { private final CharCodingConfig charCodingConfig; private final H2StreamListener streamListener; private final FrameFactory frameFactory; + private final List originSet; + /** + * @since 5.5 + */ public ServerH2StreamMultiplexerFactory( final HttpProcessor httpProcessor, final HandlerFactory exchangeHandlerFactory, final H2Config h2Config, final CharCodingConfig charCodingConfig, final H2StreamListener streamListener, - final FrameFactory frameFactory) { + final FrameFactory frameFactory, + final Collection originSet) { this.httpProcessor = Args.notNull(httpProcessor, "HTTP processor"); this.exchangeHandlerFactory = Args.notNull(exchangeHandlerFactory, "Exchange handler factory"); this.h2Config = h2Config != null ? h2Config : H2Config.DEFAULT; this.charCodingConfig = charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT; this.streamListener = streamListener; this.frameFactory = frameFactory != null ? frameFactory : DefaultFrameFactory.INSTANCE; + this.originSet = originSet != null ? H2OriginFrameCodec.normalize(originSet) : null; + } + + public ServerH2StreamMultiplexerFactory( + final HttpProcessor httpProcessor, + final HandlerFactory exchangeHandlerFactory, + final H2Config h2Config, + final CharCodingConfig charCodingConfig, + final H2StreamListener streamListener, + final FrameFactory frameFactory) { + this(httpProcessor, exchangeHandlerFactory, h2Config, charCodingConfig, streamListener, frameFactory, null); } public ServerH2StreamMultiplexerFactory( @@ -88,7 +108,8 @@ public ServerH2StreamMultiplexer create(final ProtocolIOSession ioSession) { exchangeHandlerFactory, charCodingConfig, h2Config, - streamListener); + streamListener, + originSet); } } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/bootstrap/H2ServerBootstrap.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/bootstrap/H2ServerBootstrap.java index 28bf832e82..cf6000f91d 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/bootstrap/H2ServerBootstrap.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/bootstrap/H2ServerBootstrap.java @@ -27,11 +27,13 @@ package org.apache.hc.core5.http2.impl.nio.bootstrap; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import org.apache.hc.core5.function.Callback; import org.apache.hc.core5.function.Decorator; import org.apache.hc.core5.function.Supplier; +import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpRequestMapper; import org.apache.hc.core5.http.config.CharCodingConfig; import org.apache.hc.core5.http.config.Http1Config; @@ -108,6 +110,7 @@ public class H2ServerBootstrap { private Http1StreamListener http1StreamListener; private IOReactorMetricsListener threadPoolListener; private FrameFactory frameFactory; + private List originSet; private H2ServerBootstrap() { this.routeEntries = new ArrayList<>(); @@ -170,6 +173,28 @@ public final H2ServerBootstrap setH2Config(final H2Config h2Config) { return this; } + /** + * Configures the Origin Set advertised immediately after the + * server's HTTP/2 SETTINGS frame. An empty collection advertises only the + * connection's initial origin. A {@code null} value disables advertising. + * ORIGIN frames are never sent over cleartext HTTP/2. + * + * @param origins origins served authoritatively by the same TLS connection. + * @return this instance. + * @since 5.5 + */ + public final H2ServerBootstrap setOriginSet(final Collection origins) { + if (origins == null) { + this.originSet = null; + } else { + this.originSet = new ArrayList<>(origins.size()); + for (final HttpHost origin : origins) { + this.originSet.add(Args.notNull(origin, "Origin")); + } + } + return this; + } + /** * Sets HTTP/1.1 protocol parameters * @@ -528,7 +553,8 @@ public HttpAsyncServer create() { h2Config != null ? h2Config : DEFAULT_H2_CONFIG, charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT, h2StreamListener, - frameFactory); + frameFactory, + originSet); final TlsStrategy actualTlsStrategy = tlsStrategy != null ? tlsStrategy : new H2ServerTlsStrategy(); diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/config/H2ConfigTest.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/config/H2ConfigTest.java index a6a2859d5f..fbf1eab2f3 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/config/H2ConfigTest.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/config/H2ConfigTest.java @@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -54,6 +55,8 @@ void checkValues() { .setMaxFrameSize(16384) .setPushEnabled(true) .setCompressionEnabled(true) + .setOriginFrameEnabled(false) + .setMaxOriginSetSize(42) .build(); assertEquals(1, h2Config.getHeaderTableSize()); @@ -61,6 +64,8 @@ void checkValues() { assertEquals(16384, h2Config.getMaxFrameSize()); assertTrue(h2Config.isPushEnabled()); assertTrue(h2Config.isCompressionEnabled()); + assertFalse(h2Config.isOriginFrameEnabled()); + assertEquals(42, h2Config.getMaxOriginSetSize()); } @Test @@ -72,6 +77,8 @@ void copy() { .setMaxFrameSize(16384) .setPushEnabled(true) .setCompressionEnabled(true) + .setOriginFrameEnabled(false) + .setMaxOriginSetSize(42) .build(); final H2Config.Builder builder = H2Config.copy(h2Config); @@ -82,9 +89,12 @@ void copy() { () -> assertEquals(h2Config.getInitialWindowSize(), h2Config2.getInitialWindowSize()), () -> assertEquals(h2Config.getMaxConcurrentStreams(), h2Config2.getMaxConcurrentStreams()), () -> assertEquals(h2Config.getMaxFrameSize(), h2Config2.getMaxFrameSize()), - () -> assertEquals(h2Config.getMaxHeaderListSize(), h2Config2.getMaxHeaderListSize()) + () -> assertEquals(h2Config.getMaxHeaderListSize(), h2Config2.getMaxHeaderListSize()), + () -> assertEquals(h2Config.getMaxContinuations(), h2Config2.getMaxContinuations()), + () -> assertEquals(h2Config.isOriginFrameEnabled(), h2Config2.isOriginFrameEnabled()), + () -> assertEquals(h2Config.getMaxOriginSetSize(), h2Config2.getMaxOriginSetSize()) ); } -} \ No newline at end of file +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/examples/H2OriginFrameServerExample.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/examples/H2OriginFrameServerExample.java new file mode 100644 index 0000000000..ca390cd64d --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/examples/H2OriginFrameServerExample.java @@ -0,0 +1,117 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.examples; + +import java.io.File; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.core5.function.Supplier; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Message; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; +import org.apache.hc.core5.http.nio.support.AsyncServerPipeline; +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.impl.nio.bootstrap.H2ServerBootstrap; +import org.apache.hc.core5.http2.ssl.H2ServerTlsStrategy; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.ListenerEndpoint; +import org.apache.hc.core5.ssl.SSLContexts; +import org.apache.hc.core5.util.TimeValue; + +/** + * TLS HTTP/2 server that advertises alternative origins with the ORIGIN frame. + * The certificate in the supplied PKCS#12 file must cover every advertised + * host before a client can safely coalesce requests onto this connection. + * + *
{@code
+ * H2OriginFrameServerExample server.p12 changeit 8443 \
+ *     https://assets.example.test https://api.example.test:8443
+ * }
+ */ +public class H2OriginFrameServerExample { + + public static void main(final String[] args) throws Exception { + if (args.length < 2) { + System.err.println("Usage: H2OriginFrameServerExample " + + "[port] [origin ...]"); + System.exit(1); + } + + final File keyStore = new File(args[0]); + final char[] password = args[1].toCharArray(); + final int port = args.length > 2 ? Integer.parseInt(args[2]) : 8443; + final List originSet = new ArrayList<>(); + for (int i = 3; i < args.length; i++) { + originSet.add(HttpHost.create(args[i])); + } + + final SSLContext sslContext = SSLContexts.custom() + .setKeyStoreType("pkcs12") + .loadKeyMaterial(keyStore.toURI().toURL(), password, password) + .build(); + + final Supplier handlerSupplier = AsyncServerPipeline.assemble() + .request(Method.GET) + .ignoreContent() + .response() + .asString(ContentType.TEXT_PLAIN) + .handle((request, context) -> Message.of( + new BasicHttpResponse(HttpStatus.SC_OK), + "Served over the ORIGIN-advertised connection for " + request.head().getAuthority() + "\n")) + .supplier(); + + final H2ServerBootstrap bootstrap = H2ServerBootstrap.bootstrap() + .setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_2) + .setTlsStrategy(new H2ServerTlsStrategy(sslContext)) + .setOriginSet(originSet) + .register("*", handlerSupplier); + // Serve every advertised origin authoritatively, so the connection can + // honour the origins it announces in the ORIGIN frame. + for (final HttpHost origin : originSet) { + bootstrap.register(origin.getHostName(), "*", handlerSupplier); + } + final HttpAsyncServer server = bootstrap.create(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> server.close(CloseMode.GRACEFUL))); + server.start(); + final Future future = server.listen(new InetSocketAddress(port), URIScheme.HTTPS); + System.out.println("Listening on " + future.get().getAddress()); + System.out.println("Advertised Origin Set: " + originSet); + server.awaitShutdown(TimeValue.ofDays(Long.MAX_VALUE)); + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/frame/TestDefaultFrameFactory.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/frame/TestDefaultFrameFactory.java index f5cbe857b8..1c672673a1 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/frame/TestDefaultFrameFactory.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/frame/TestDefaultFrameFactory.java @@ -99,4 +99,17 @@ void testGoAwayFrame() { Assertions.assertEquals("Oopsie", new String(tmp, StandardCharsets.US_ASCII)); } + @Test + void testOriginFrame() { + final FrameFactory frameFactory = new DefaultFrameFactory(); + final ByteBuffer payload = ByteBuffer.wrap(new byte[] {0, 0}); + + final Frame originFrame = frameFactory.createOrigin(payload); + + Assertions.assertEquals(FrameType.ORIGIN.value, originFrame.getType()); + Assertions.assertEquals(0, originFrame.getStreamId()); + Assertions.assertEquals(0, originFrame.getFlags()); + Assertions.assertEquals(2, originFrame.getPayload().remaining()); + } + } diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2OriginEnforcement.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2OriginEnforcement.java new file mode 100644 index 0000000000..075f2e427e --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2OriginEnforcement.java @@ -0,0 +1,154 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.MisdirectedRequestException; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.impl.BasicHttpConnectionMetrics; +import org.apache.hc.core5.http.impl.BasicHttpTransportMetrics; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; +import org.apache.hc.core5.http.nio.AsyncPushConsumer; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.nio.RequestChannel; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.H2Error; +import org.apache.hc.core5.http2.H2StreamResetException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +class TestClientH2OriginEnforcement { + + private static final HttpHost INITIAL = new HttpHost("https", "primary.example", 443); + private static final HttpHost ALT = new HttpHost("https", "assets.example", 443); + + @Test + void rejectsAbsentOriginBeforeSubmittingHeaders() throws Exception { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 10); + originSet.update(Collections.emptyList()); + final Fixture fixture = new Fixture(originSet, new BasicHttpRequest(Method.GET, ALT, "/asset.js")); + + Assertions.assertThrows(MisdirectedRequestException.class, fixture.handler::produceOutput); + + Mockito.verify(fixture.channel, Mockito.never()).submit(ArgumentMatchers.anyList(), ArgumentMatchers.anyBoolean()); + } + + @Test + void submitsHeadersForAdvertisedOrigin() throws Exception { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 10); + originSet.update(Collections.singleton(ALT)); + final Fixture fixture = new Fixture(originSet, new BasicHttpRequest(Method.GET, ALT, "/asset.js")); + + fixture.handler.produceOutput(); + + Mockito.verify(fixture.channel).submit(ArgumentMatchers.anyList(), Mockito.eq(true)); + } + + @Test + void response421RemovesRequestOrigin() throws Exception { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 10); + originSet.update(Collections.singleton(ALT)); + final Fixture fixture = new Fixture(originSet, new BasicHttpRequest(Method.GET, ALT, "/asset.js")); + fixture.handler.produceOutput(); + + fixture.handler.consumeHeader( + Collections.singletonList(new BasicHeader(":status", "421")), true); + + Assertions.assertFalse(originSet.snapshot().contains(ALT)); + Assertions.assertTrue(originSet.isInitialized()); + } + + @Test + void refusesPushPromiseForOriginAbsentFromInitializedSet() throws Exception { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 10); + originSet.update(Collections.emptyList()); + @SuppressWarnings("unchecked") + final HandlerFactory pushHandlerFactory = + (HandlerFactory) Mockito.mock(HandlerFactory.class); + final ClientPushH2StreamHandler handler = new ClientPushH2StreamHandler( + Mockito.mock(H2StreamChannel.class), + Mockito.mock(HttpProcessor.class), + new BasicHttpConnectionMetrics( + new BasicHttpTransportMetrics(), new BasicHttpTransportMetrics()), + pushHandlerFactory, + HttpCoreContext.create(), + originSet); + + final H2StreamResetException ex = Assertions.assertThrows(H2StreamResetException.class, () -> + handler.consumePromise(promiseHeaders(ALT))); + + Assertions.assertEquals(H2Error.REFUSED_STREAM.getCode(), ex.getCode()); + Mockito.verify(pushHandlerFactory, Mockito.never()).create(ArgumentMatchers.any(), ArgumentMatchers.any()); + } + + private static List
promiseHeaders(final HttpHost origin) { + return Arrays.asList( + new BasicHeader(":method", "GET"), + new BasicHeader(":scheme", origin.getSchemeName()), + new BasicHeader(":authority", origin.toHostString()), + new BasicHeader(":path", "/asset.js")); + } + + private static final class Fixture { + + private final H2StreamChannel channel; + private final ClientH2StreamHandler handler; + + private Fixture(final H2OriginSet originSet, final BasicHttpRequest request) throws Exception { + channel = Mockito.mock(H2StreamChannel.class); + final HttpProcessor httpProcessor = Mockito.mock(HttpProcessor.class); + final AsyncClientExchangeHandler exchangeHandler = Mockito.mock(AsyncClientExchangeHandler.class); + @SuppressWarnings("unchecked") + final HandlerFactory pushHandlerFactory = + (HandlerFactory) Mockito.mock(HandlerFactory.class); + handler = new ClientH2StreamHandler( + channel, + httpProcessor, + new BasicHttpConnectionMetrics( + new BasicHttpTransportMetrics(), new BasicHttpTransportMetrics()), + exchangeHandler, + pushHandlerFactory, + HttpCoreContext.create(), + originSet); + Mockito.doAnswer(invocation -> { + final RequestChannel requestChannel = invocation.getArgument(0); + requestChannel.sendRequest(request, null, invocation.getArgument(1)); + return null; + }).when(exchangeHandler).produceRequest(ArgumentMatchers.any(), ArgumentMatchers.any()); + } + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2OriginFrame.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2OriginFrame.java new file mode 100644 index 0000000000..dc07481836 --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2OriginFrame.java @@ -0,0 +1,198 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.concurrent.locks.ReentrantLock; + +import javax.net.ssl.SSLSession; + +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.nio.AsyncPushConsumer; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.H2ConnectionException; +import org.apache.hc.core5.http2.H2Error; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.frame.DefaultFrameFactory; +import org.apache.hc.core5.http2.frame.FrameType; +import org.apache.hc.core5.http2.frame.RawFrame; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class TestClientH2OriginFrame { + + private static final HttpHost INITIAL = new HttpHost("https", "primary.example", 443); + private static final HttpHost ALT = new HttpHost("https", "assets.example", 443); + + @Test + void initialOriginUsesActualTlsPeerPort() throws Exception { + final ProtocolIOSession ioSession = Mockito.mock(ProtocolIOSession.class); + Mockito.when(ioSession.getInitialEndpoint()).thenReturn(INITIAL); + Mockito.when(ioSession.getRemoteAddress()).thenReturn(new InetSocketAddress("127.0.0.1", 8443)); + final SSLSession sslSession = Mockito.mock(SSLSession.class); + Mockito.when(sslSession.getPeerPort()).thenReturn(8443); + Mockito.when(ioSession.getTlsDetails()).thenReturn(new TlsDetails(sslSession, "h2")); + final HttpProcessor httpProcessor = Mockito.mock(HttpProcessor.class); + @SuppressWarnings("unchecked") + final HandlerFactory pushHandlerFactory = + (HandlerFactory) Mockito.mock(HandlerFactory.class); + final ClientH2StreamMultiplexer multiplexer = new ClientH2StreamMultiplexer( + ioSession, DefaultFrameFactory.INSTANCE, httpProcessor, pushHandlerFactory, + H2Config.DEFAULT, CharCodingConfig.DEFAULT, null); + + multiplexer.consumeOriginFrame(new RawFrame( + FrameType.ORIGIN.getValue(), 0, 0, ByteBuffer.allocate(0))); + + Assertions.assertTrue(multiplexer.getOriginSet().contains( + new HttpHost("https", "primary.example", 8443))); + } + + @Test + void dispatchesOriginFrameFromHttp2WireInput() throws Exception { + final ClientH2StreamMultiplexer multiplexer = newMultiplexer(true, H2Config.DEFAULT); + multiplexer.onConnect(); + multiplexer.onInput(wireFrame(FrameType.SETTINGS.getValue(), 0, 0, null)); + final ByteBuffer payload = H2OriginFrameCodec.encode(Collections.singleton(ALT), 16384).get(0); + + multiplexer.onInput(wireFrame(FrameType.ORIGIN.getValue(), 0, 0, payload)); + + Assertions.assertTrue(multiplexer.getOriginSet().contains(ALT)); + } + + @Test + void processesOriginFrameOnTlsConnection() throws Exception { + final ClientH2StreamMultiplexer multiplexer = newMultiplexer(true, H2Config.DEFAULT); + + multiplexer.consumeOriginFrame(originFrame(0, 0, ALT)); + + Assertions.assertTrue(multiplexer.isOriginSetInitialized()); + Assertions.assertTrue(multiplexer.getOriginSet().contains(INITIAL)); + Assertions.assertTrue(multiplexer.getOriginSet().contains(ALT)); + Assertions.assertTrue(multiplexer.isOriginAllowed(ALT)); + } + + @Test + void highFlagBitsDoNotChangeProcessing() throws Exception { + final ClientH2StreamMultiplexer multiplexer = newMultiplexer(true, H2Config.DEFAULT); + multiplexer.consumeOriginFrame(originFrame(0, 0x10, ALT)); + Assertions.assertTrue(multiplexer.isOriginSetInitialized()); + } + + @Test + void ignoresFrameWithBackwardIncompatibleFlag() throws Exception { + final ClientH2StreamMultiplexer multiplexer = newMultiplexer(true, H2Config.DEFAULT); + multiplexer.consumeOriginFrame(originFrame(0, 0x01, ALT)); + Assertions.assertFalse(multiplexer.isOriginSetInitialized()); + } + + @Test + void ignoresFrameOnNonZeroStream() throws Exception { + final ClientH2StreamMultiplexer multiplexer = newMultiplexer(true, H2Config.DEFAULT); + multiplexer.consumeOriginFrame(originFrame(3, 0, ALT)); + Assertions.assertFalse(multiplexer.isOriginSetInitialized()); + } + + @Test + void ignoresFrameOverH2c() throws Exception { + final ClientH2StreamMultiplexer multiplexer = newMultiplexer(false, H2Config.DEFAULT); + multiplexer.consumeOriginFrame(originFrame(0, 0, ALT)); + Assertions.assertFalse(multiplexer.isOriginSetInitialized()); + } + + @Test + void proxyPolicyCanDisableOriginFrames() throws Exception { + final H2Config config = H2Config.custom().setOriginFrameEnabled(false).build(); + final ClientH2StreamMultiplexer multiplexer = newMultiplexer(true, config); + multiplexer.consumeOriginFrame(originFrame(0, 0, ALT)); + Assertions.assertFalse(multiplexer.isOriginSetInitialized()); + } + + @Test + void exceedingConfiguredSetLimitFailsConnection() throws Exception { + final H2Config config = H2Config.custom().setMaxOriginSetSize(1).build(); + final ClientH2StreamMultiplexer multiplexer = newMultiplexer(true, config); + + final H2ConnectionException ex = Assertions.assertThrows(H2ConnectionException.class, () -> + multiplexer.consumeOriginFrame(originFrame(0, 0, ALT))); + + Assertions.assertEquals(H2Error.ENHANCE_YOUR_CALM.getCode(), ex.getCode()); + Assertions.assertFalse(multiplexer.isOriginSetInitialized()); + } + + private static RawFrame originFrame( + final int streamId, final int flags, final HttpHost origin) { + final ByteBuffer payload = H2OriginFrameCodec.encode(Collections.singleton(origin), 16384).get(0); + return new RawFrame(FrameType.ORIGIN.getValue(), flags, streamId, payload); + } + + private static ByteBuffer wireFrame( + final int type, final int flags, final int streamId, final ByteBuffer payload) { + final int length = payload != null ? payload.remaining() : 0; + final ByteBuffer frame = ByteBuffer.allocate(9 + length); + frame.put((byte) (length >>> 16)); + frame.put((byte) (length >>> 8)); + frame.put((byte) length); + frame.put((byte) type); + frame.put((byte) flags); + frame.putInt(streamId); + if (payload != null) { + frame.put(payload.duplicate()); + } + frame.flip(); + return frame; + } + + private static ClientH2StreamMultiplexer newMultiplexer(final boolean tls, final H2Config config) { + final ProtocolIOSession ioSession = Mockito.mock(ProtocolIOSession.class); + Mockito.when(ioSession.getLock()).thenReturn(new ReentrantLock()); + Mockito.when(ioSession.getInitialEndpoint()).thenReturn(INITIAL); + Mockito.when(ioSession.getRemoteAddress()).thenReturn(new InetSocketAddress("127.0.0.1", 443)); + if (tls) { + Mockito.when(ioSession.getTlsDetails()).thenReturn( + new TlsDetails(Mockito.mock(SSLSession.class), "h2")); + } + final HttpProcessor httpProcessor = Mockito.mock(HttpProcessor.class); + @SuppressWarnings("unchecked") + final HandlerFactory pushHandlerFactory = + (HandlerFactory) Mockito.mock(HandlerFactory.class); + return new ClientH2StreamMultiplexer( + ioSession, + DefaultFrameFactory.INSTANCE, + httpProcessor, + pushHandlerFactory, + config, + CharCodingConfig.DEFAULT, + null); + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2OriginFrameCodec.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2OriginFrameCodec.java new file mode 100644 index 0000000000..70528502f8 --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2OriginFrameCodec.java @@ -0,0 +1,146 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.apache.hc.core5.http.HttpHost; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TestH2OriginFrameCodec { + + @Test + void parsesAndNormalizesAsciiOrigins() throws Exception { + Assertions.assertEquals( + new HttpHost("https", "example.com", 443), + H2OriginFrameCodec.parse("HTTPS://EXAMPLE.COM")); + Assertions.assertEquals( + new HttpHost("https", "example.com", 8443), + H2OriginFrameCodec.parse("https://example.com:8443")); + Assertions.assertEquals( + new HttpHost("https", "2001:db8::1", 443), + H2OriginFrameCodec.parse("https://[2001:db8::1]")); + Assertions.assertEquals( + new HttpHost("https", "bücher.example", 443), + H2OriginFrameCodec.parse("https://xn--bcher-kva.example")); + Assertions.assertEquals( + new HttpHost("custom", "example.com", 9443), + H2OriginFrameCodec.parse("custom://example.com:9443")); + } + + @Test + void formatsRfc6454AsciiSerialization() { + Assertions.assertEquals( + "https://example.com", + H2OriginFrameCodec.format(new HttpHost("https", "example.com", 443))); + Assertions.assertEquals( + "https://example.com", + H2OriginFrameCodec.format(new HttpHost("HTTPS", "EXAMPLE.COM", 443))); + Assertions.assertEquals( + "https://example.com:8443", + H2OriginFrameCodec.format(new HttpHost("https", "example.com", 8443))); + Assertions.assertEquals( + "https://[2001:db8::1]", + H2OriginFrameCodec.format(new HttpHost("https", "2001:db8::1", 443))); + Assertions.assertEquals( + "https://xn--bcher-kva.example", + H2OriginFrameCodec.format(new HttpHost("https", "bücher.example", 443))); + } + + @Test + void rejectsValuesThatAreNotAsciiOriginSerializations() { + final List invalid = Arrays.asList( + "", + "example.com", + "1https://example.com", + "https:/example.com", + "https://", + "https://user@example.com", + "https://*.example.com", + "https://[fe80::1%25eth0]", + "https://example.com/", + "https://example.com?x=1", + "https://example.com#fragment", + "https://example.com:", + "https://example.com:+443", + "https://example.com:44x", + "https://bücher.example", + "custom://example.com"); + for (final String value : invalid) { + Assertions.assertThrows(URISyntaxException.class, () -> H2OriginFrameCodec.parse(value), value); + } + Assertions.assertThrows(IllegalArgumentException.class, () -> + H2OriginFrameCodec.format(new HttpHost("https", "bad/host", 443))); + } + + @Test + void decodesValidEntriesAndIgnoresInvalidOnes() { + final byte[] valid = "https://one.example".getBytes(StandardCharsets.US_ASCII); + final byte[] withPath = "https://bad.example/".getBytes(StandardCharsets.US_ASCII); + final byte[] nonAscii = new byte[] {'h', 't', 't', 'p', 's', ':', '/', '/', (byte) 0xff}; + final ByteBuffer payload = ByteBuffer.allocate( + 2 + valid.length + 2 + withPath.length + 2 + nonAscii.length + 3); + payload.putShort((short) valid.length).put(valid); + payload.putShort((short) withPath.length).put(withPath); + payload.putShort((short) nonAscii.length).put(nonAscii); + payload.putShort((short) 10).put((byte) 'x'); // truncated final entry + payload.flip(); + + final Set result = H2OriginFrameCodec.decode(payload); + + Assertions.assertEquals(Collections.singleton(new HttpHost("https", "one.example", 443)), result); + } + + @Test + void encodesEntriesAndSplitsOnlyAtEntryBoundaries() { + final List origins = Arrays.asList( + new HttpHost("https", "a.example", 443), + new HttpHost("https", "b.example", 8443)); + + final List payloads = H2OriginFrameCodec.encode(origins, 25); + + Assertions.assertEquals(2, payloads.size()); + Assertions.assertEquals(Collections.singleton(new HttpHost("https", "a.example", 443)), + H2OriginFrameCodec.decode(payloads.get(0))); + Assertions.assertEquals(Collections.singleton(new HttpHost("https", "b.example", 8443)), + H2OriginFrameCodec.decode(payloads.get(1))); + } + + @Test + void encodesEmptyOriginSetAsOneEmptyPayload() { + final List payloads = H2OriginFrameCodec.encode(Collections.emptyList(), 16384); + Assertions.assertEquals(1, payloads.size()); + Assertions.assertEquals(0, payloads.get(0).remaining()); + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2OriginSet.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2OriginSet.java new file mode 100644 index 0000000000..a8f36ca3af --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2OriginSet.java @@ -0,0 +1,93 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.util.Arrays; +import java.util.Collections; + +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.MisdirectedRequestException; +import org.apache.hc.core5.http2.H2ConnectionException; +import org.apache.hc.core5.http2.H2Error; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TestH2OriginSet { + + private static final HttpHost INITIAL = new HttpHost("https", "primary.example", 443); + private static final HttpHost ALT = new HttpHost("https", "assets.example", 443); + + @Test + void startsUninitializedAndAllowsAnyOrigin() { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 10); + Assertions.assertFalse(originSet.isInitialized()); + Assertions.assertTrue(originSet.isAllowed(ALT)); + Assertions.assertTrue(originSet.snapshot().isEmpty()); + } + + @Test + void firstFrameAddsInitialOriginAndLaterFramesMerge() throws Exception { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 10); + originSet.update(Collections.emptyList()); + originSet.update(Collections.singleton(ALT)); + + Assertions.assertTrue(originSet.isInitialized()); + Assertions.assertEquals(2, originSet.snapshot().size()); + Assertions.assertTrue(originSet.snapshot().contains(INITIAL)); + Assertions.assertTrue(originSet.snapshot().contains(ALT)); + } + + @Test + void initializedSetRejectsAbsentOrigin() throws Exception { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 10); + originSet.update(Collections.emptyList()); + Assertions.assertThrows(MisdirectedRequestException.class, () -> originSet.ensureAllowed(ALT)); + } + + @Test + void removalLeavesSetInitialized() throws Exception { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 10); + originSet.update(Collections.singleton(ALT)); + originSet.remove(ALT); + Assertions.assertTrue(originSet.isInitialized()); + Assertions.assertFalse(originSet.snapshot().contains(ALT)); + } + + @Test + void enforcesConfiguredResourceLimitAtomically() throws Exception { + final H2OriginSet originSet = new H2OriginSet(INITIAL, 2); + originSet.update(Collections.singleton(ALT)); + + final H2ConnectionException ex = Assertions.assertThrows(H2ConnectionException.class, () -> + originSet.update(Arrays.asList( + new HttpHost("https", "third.example", 443), + new HttpHost("https", "fourth.example", 443)))); + + Assertions.assertEquals(H2Error.ENHANCE_YOUR_CALM.getCode(), ex.getCode()); + Assertions.assertEquals(2, originSet.snapshot().size()); + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2StreamOriginMismatch.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2StreamOriginMismatch.java new file mode 100644 index 0000000000..364851cc4d --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2StreamOriginMismatch.java @@ -0,0 +1,55 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +class TestH2StreamOriginMismatch { + + @Test + void localOriginRejectionDoesNotResetIdleStream() throws Exception { + final H2StreamChannel channel = Mockito.mock(H2StreamChannel.class); + Mockito.when(channel.getInputWindow()).thenReturn(new AtomicInteger(65535)); + Mockito.when(channel.getOutputWindow()).thenReturn(new AtomicInteger(65535)); + final H2StreamHandler handler = Mockito.mock(H2StreamHandler.class); + final H2OriginMismatchException mismatch = new H2OriginMismatchException("not advertised"); + Mockito.doThrow(mismatch).when(handler).produceOutput(); + final H2Stream stream = new H2Stream(channel, handler, null); + stream.activate(); + + stream.produceOutput(); + + Mockito.verify(channel).markLocalClosed(); + Mockito.verify(channel, Mockito.never()).localReset(ArgumentMatchers.anyInt()); + Mockito.verify(handler).failed(mismatch); + Mockito.verify(handler).releaseResources(); + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestServerH2OriginFrame.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestServerH2OriginFrame.java new file mode 100644 index 0000000000..dd4c2fa22d --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestServerH2OriginFrame.java @@ -0,0 +1,113 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.locks.ReentrantLock; + +import javax.net.ssl.SSLSession; + +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.config.CharCodingConfig; +import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.frame.DefaultFrameFactory; +import org.apache.hc.core5.http2.frame.FrameFactory; +import org.apache.hc.core5.reactor.ProtocolIOSession; +import org.apache.hc.core5.reactor.ssl.TlsDetails; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +class TestServerH2OriginFrame { + + @Test + void sendsConfiguredOriginSetImmediatelyAfterConnect() throws Exception { + final FrameFactory frameFactory = Mockito.spy(new DefaultFrameFactory()); + final ServerH2StreamMultiplexer multiplexer = newMultiplexer( + true, + frameFactory, + Arrays.asList( + new HttpHost("https", "assets.example", 443), + new HttpHost("https", "api.example", 8443))); + + multiplexer.onConnect(); + + Mockito.verify(frameFactory).createOrigin(ArgumentMatchers.any(ByteBuffer.class)); + } + + @Test + void sendsEmptyOriginSetAdvertisement() throws Exception { + final FrameFactory frameFactory = Mockito.spy(new DefaultFrameFactory()); + final ServerH2StreamMultiplexer multiplexer = newMultiplexer( + true, frameFactory, Collections.emptyList()); + + multiplexer.sendOriginSet(Collections.emptyList()); + + Mockito.verify(frameFactory).createOrigin(ArgumentMatchers.argThat(payload -> payload.remaining() == 0)); + } + + @Test + void neverSendsOriginFrameOverH2c() throws Exception { + final FrameFactory frameFactory = Mockito.spy(new DefaultFrameFactory()); + final ServerH2StreamMultiplexer multiplexer = newMultiplexer( + false, frameFactory, Collections.singleton(new HttpHost("https", "assets.example", 443))); + + multiplexer.onConnect(); + + Mockito.verify(frameFactory, Mockito.never()).createOrigin(ArgumentMatchers.any()); + } + + private static ServerH2StreamMultiplexer newMultiplexer( + final boolean tls, + final FrameFactory frameFactory, + final java.util.Collection origins) { + final ProtocolIOSession ioSession = Mockito.mock(ProtocolIOSession.class); + Mockito.when(ioSession.getLock()).thenReturn(new ReentrantLock()); + if (tls) { + Mockito.when(ioSession.getTlsDetails()).thenReturn( + new TlsDetails(Mockito.mock(SSLSession.class), "h2")); + } + final HttpProcessor httpProcessor = Mockito.mock(HttpProcessor.class); + @SuppressWarnings("unchecked") + final HandlerFactory exchangeHandlerFactory = + (HandlerFactory) Mockito.mock(HandlerFactory.class); + return new ServerH2StreamMultiplexer( + ioSession, + frameFactory, + httpProcessor, + exchangeHandlerFactory, + CharCodingConfig.DEFAULT, + H2Config.DEFAULT, + null, + origins); + } +}