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 96146ce91..00c68113f 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 @@ -104,6 +104,8 @@ abstract class AbstractH2StreamMultiplexer implements Identifiable, HttpConnection { + private final H2ControlFrameRateLimiter controlFrameRateLimiter; + private static final long CONNECTION_WINDOW_LOW_MARK = 10 * 1024 * 1024; enum ConnectionHandshake { READY, ACTIVE, GRACEFUL_SHUTDOWN, SHUTDOWN } @@ -188,6 +190,21 @@ enum SettingsHandshake { READY, TRANSMITTED, ACKED } final H2StreamListener streamListener, final Timeout validateAfterInactivity, final Timeout pingAckTimeout) { + this(ioSession, frameFactory, idGenerator, httpProcessor, charCodingConfig, h2Config, streamListener, + validateAfterInactivity, pingAckTimeout, new H2ControlFrameRateLimiter()); + } + + AbstractH2StreamMultiplexer( + final ProtocolIOSession ioSession, + final FrameFactory frameFactory, + final StreamIdGenerator idGenerator, + final HttpProcessor httpProcessor, + final CharCodingConfig charCodingConfig, + final H2Config h2Config, + final H2StreamListener streamListener, + final Timeout validateAfterInactivity, + final Timeout pingAckTimeout, + final H2ControlFrameRateLimiter controlFrameRateLimiter) { this.ioSession = Args.notNull(ioSession, "IO session"); this.frameFactory = Args.notNull(frameFactory, "Frame factory"); this.httpProcessor = Args.notNull(httpProcessor, "HTTP processor"); @@ -216,6 +233,7 @@ enum SettingsHandshake { READY, TRANSMITTED, ACKED } this.lastActivityTime = System.currentTimeMillis(); this.validateAfterInactivity = validateAfterInactivity; this.pingAckTimeout = Args.notNull(pingAckTimeout, "PING ACK timeout"); + this.controlFrameRateLimiter = Args.notNull(controlFrameRateLimiter, "Control frame rate limiter"); } @Override @@ -976,7 +994,10 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio pingHandler.consumeResponse(ping); } } else { - final ByteBuffer pong = ByteBuffer.allocate(ping.remaining()); + if (!controlFrameRateLimiter.accept(System.nanoTime())) { + throw new H2ConnectionException(H2Error.ENHANCE_YOUR_CALM, "Excessive HTTP/2 PING / SETTINGS frames"); + } + final ByteBuffer pong = ByteBuffer.allocate(ping.remaining()); pong.put(ping); pong.flip(); final RawFrame response = frameFactory.createPingAck(pong); @@ -1005,10 +1026,15 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio } } else { final ByteBuffer payload = frame.getPayload(); + if (payload != null && (payload.remaining() % 6) != 0) { + throw new H2ConnectionException(H2Error.FRAME_SIZE_ERROR, + "Invalid SETTINGS payload"); + } + if (!controlFrameRateLimiter.accept(System.nanoTime())) { + throw new H2ConnectionException(H2Error.ENHANCE_YOUR_CALM, + "Excessive HTTP/2 PING / SETTINGS frames"); + } if (payload != null) { - if ((payload.remaining() % 6) != 0) { - throw new H2ConnectionException(H2Error.FRAME_SIZE_ERROR, "Invalid SETTINGS payload"); - } consumeSettingsFrame(payload); remoteSettingState = SettingsHandshake.TRANSMITTED; } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2ControlFrameRateLimiter.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2ControlFrameRateLimiter.java new file mode 100644 index 000000000..77a696ecd --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2ControlFrameRateLimiter.java @@ -0,0 +1,100 @@ +/* + * ==================================================================== + * 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.util.Args; + +/** + * Per-connection resource policy for unsolicited HTTP/2 control frames. + * + *

The limiter is owned and called by a single HTTP/2 I/O session. Only + * non-ACK PING and SETTINGS frames are charged. ACK frames, DATA, + * WINDOW_UPDATE and RST_STREAM are not charged, as they can represent + * legitimate high-throughput or highly concurrent exchanges.

+ * + *

Uses a small token bucket to tolerate bursts and recover over time, + * with no locks or per-frame allocation. This is a resource safeguard, + * not an RFC 9113 protocol-validity rule.

+ */ +final class H2ControlFrameRateLimiter { + + static final int CAPACITY = 256; + static final int REFILL_PER_SECOND = 128; + private static final long NANOS_PER_SECOND = 1_000_000_000L; + + private final int capacity; + private final int refillPerSecond; + private final long fullRefillNanos; + + private int tokens; + private long previousNanos; + private long remainder; + private boolean initialized; + + H2ControlFrameRateLimiter(final int capacity, final int refillPerSecond) { + this.capacity = Args.positive(capacity, "Capacity"); + this.refillPerSecond = Args.positive(refillPerSecond, "Refill per second"); + this.fullRefillNanos = (NANOS_PER_SECOND * capacity + refillPerSecond - 1) / refillPerSecond; + this.tokens = capacity; + } + + H2ControlFrameRateLimiter() { + this(CAPACITY, REFILL_PER_SECOND); + } + + boolean accept(final long nowNanos) { + if (!initialized) { + initialized = true; + previousNanos = nowNanos; + } else { + final long elapsed = nowNanos - previousNanos; + // Avoid overflow and do not refill on a backward clock jump. + if (elapsed > 0) { + previousNanos = nowNanos; + if (elapsed >= fullRefillNanos) { + tokens = capacity; + remainder = 0; + } else { + final long numerator = elapsed * refillPerSecond + remainder; + final long refill = numerator / NANOS_PER_SECOND; + remainder = numerator % NANOS_PER_SECOND; + if (refill > 0) { + tokens = (int) Math.min(capacity, (long) tokens + refill); + if (tokens == capacity) { + remainder = 0; + } + } + } + } + } + if (tokens == 0) { + return false; + } + tokens--; + return true; + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java index 5ec17cd97..54788d68a 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java @@ -156,6 +156,23 @@ public H2StreamMultiplexerImpl( this.streamHandlerSupplier = streamHandlerSupplier; } + public H2StreamMultiplexerImpl( + final ProtocolIOSession ioSession, + final FrameFactory frameFactory, + final StreamIdGenerator idGenerator, + final HttpProcessor httpProcessor, + final CharCodingConfig charCodingConfig, + final H2Config h2Config, + final H2StreamListener streamListener, + final Supplier streamHandlerSupplier, + final Timeout validateAfterInactivity, + final Timeout pingAckTimeout, + final H2ControlFrameRateLimiter controlFrameRateLimiter) { + super(ioSession, frameFactory, idGenerator, httpProcessor, charCodingConfig, h2Config, streamListener, + validateAfterInactivity, pingAckTimeout, controlFrameRateLimiter); + this.streamHandlerSupplier = streamHandlerSupplier; + } + @Override void validateSetting(final H2Param param, final int value) throws H2ConnectionException { } @@ -2547,4 +2564,41 @@ void testDataOnReservedRemoteStreamIsConnectionProtocolError() throws Exception ArgumentMatchers.anyBoolean()); } + + @Test + void testMalformedSettingsAfterOverheadLimitReportsFrameSizeError() throws Exception { + // A limiter without tokens that cannot refill: the multiplexer takes its timestamps + // from System#nanoTime(), which is always earlier than the one used here. + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(1, 1); + Assertions.assertTrue(limiter.accept(Long.MAX_VALUE)); + + final AbstractH2StreamMultiplexer mux = new H2StreamMultiplexerImpl( + protocolIOSession, + FRAME_FACTORY, + StreamIdGenerator.ODD, + httpProcessor, + CharCodingConfig.DEFAULT, + H2Config.custom().build(), + h2StreamListener, + () -> streamHandler, + null, + Timeout.ofSeconds(5), + limiter); + + final RawFrame malformed = new RawFrame( + FrameType.SETTINGS.getValue(), 0, 0, ByteBuffer.wrap(new byte[1])); + final H2ConnectionException frameSizeError = Assertions.assertThrows( + H2ConnectionException.class, + () -> feedFrame(mux, malformed)); + Assertions.assertEquals(H2Error.FRAME_SIZE_ERROR, + H2Error.getByCode(frameSizeError.getCode())); + + final RawFrame valid = new RawFrame( + FrameType.SETTINGS.getValue(), 0, 0, ByteBuffer.allocate(0)); + final H2ConnectionException overheadError = Assertions.assertThrows( + H2ConnectionException.class, + () -> feedFrame(mux, valid)); + Assertions.assertEquals(H2Error.ENHANCE_YOUR_CALM, + H2Error.getByCode(overheadError.getCode())); + } } \ No newline at end of file diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2ControlFrameRateLimiter.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2ControlFrameRateLimiter.java new file mode 100644 index 000000000..26a9256df --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestH2ControlFrameRateLimiter.java @@ -0,0 +1,150 @@ +/* + * ==================================================================== + * 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.TimeUnit; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TestH2ControlFrameRateLimiter { + + private static final long SECOND = TimeUnit.SECONDS.toNanos(1); + + private static void assertAccepted(final H2ControlFrameRateLimiter limiter, final int count, final long nowNanos) { + for (int i = 0; i < count; i++) { + Assertions.assertTrue(limiter.accept(nowNanos), "frame " + (i + 1) + " of " + count); + } + } + + @Test + void testInitialCapacity() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); + + assertAccepted(limiter, 4, 0L); + } + + @Test + void testExhaustion() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); + + assertAccepted(limiter, 4, 0L); + Assertions.assertFalse(limiter.accept(0L)); + } + + @Test + void testProportionalRefill() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); + assertAccepted(limiter, 4, 0L); + + // 2 frames per second: one second brings back 2 frames, not 4 + assertAccepted(limiter, 2, SECOND); + Assertions.assertFalse(limiter.accept(SECOND)); + + assertAccepted(limiter, 2, 2 * SECOND); + Assertions.assertFalse(limiter.accept(2 * SECOND)); + } + + @Test + void testFractionalRefill() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); + assertAccepted(limiter, 4, 0L); + + // 0.25 s at 2 frames per second is half a frame + Assertions.assertFalse(limiter.accept(SECOND / 4)); + // another 0.25 s completes the frame + Assertions.assertTrue(limiter.accept(SECOND / 2)); + Assertions.assertFalse(limiter.accept(SECOND / 2)); + } + + @Test + void testRecoveryAfterRejection() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); + assertAccepted(limiter, 4, 0L); + Assertions.assertFalse(limiter.accept(0L)); + + Assertions.assertTrue(limiter.accept(SECOND)); + } + + @Test + void testRefillDoesNotExceedCapacity() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); + assertAccepted(limiter, 1, 0L); + + // 3 frames left, one second adds 2: 5 would exceed the capacity of 4 + assertAccepted(limiter, 4, SECOND); + Assertions.assertFalse(limiter.accept(SECOND)); + } + + @Test + void testIdleTimeRestoresCapacityOnly() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); + assertAccepted(limiter, 4, 0L); + + assertAccepted(limiter, 4, 10 * SECOND); + Assertions.assertFalse(limiter.accept(10 * SECOND)); + } + + @Test + void testCustomParameters() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(10, 5); + + assertAccepted(limiter, 10, 0L); + Assertions.assertFalse(limiter.accept(0L)); + + assertAccepted(limiter, 5, SECOND); + Assertions.assertFalse(limiter.accept(SECOND)); + } + + @Test + void testDefaultParameters() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(); + + assertAccepted(limiter, H2ControlFrameRateLimiter.CAPACITY, 0L); + Assertions.assertFalse(limiter.accept(0L)); + + assertAccepted(limiter, H2ControlFrameRateLimiter.REFILL_PER_SECOND, SECOND); + Assertions.assertFalse(limiter.accept(SECOND)); + } + + @Test + void testClockRollbackDoesNotRefill() { + final H2ControlFrameRateLimiter limiter = new H2ControlFrameRateLimiter(4, 2); + assertAccepted(limiter, 4, 100L); + + Assertions.assertFalse(limiter.accept(99L)); + } + + @Test + void testInvalidParameters() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new H2ControlFrameRateLimiter(0, 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> new H2ControlFrameRateLimiter(-1, 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> new H2ControlFrameRateLimiter(1, 0)); + Assertions.assertThrows(IllegalArgumentException.class, () -> new H2ControlFrameRateLimiter(1, -1)); + } + +}