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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* <http://www.apache.org/>.
*
*/
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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<H2StreamHandler> 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 {
}
Expand Down Expand Up @@ -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()));
}
}
Original file line number Diff line number Diff line change
@@ -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
* <http://www.apache.org/>.
*
*/
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));
}

}
Loading