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
32 changes: 28 additions & 4 deletions src/main/java/core/packetproxy/http2/FrameManager.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 DeNA Co., Ltd.
* Copyright 2019,2026 DeNA Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -37,11 +37,16 @@ public class FrameManager {
private boolean flag_receive_peer_settings = false;
private boolean flag_send_settings = false;
private boolean flag_send_end_settings = false;
private StreamIdRemapper streamIdRemapper = null;

public FrameManager() throws Exception {
flowControlManager = new FlowControlManager();
}

public void setStreamIdRemapper(StreamIdRemapper streamIdRemapper) {
this.streamIdRemapper = streamIdRemapper;
}

public HpackDecoder getHpackDecoder() {
return hpackDecoder;
}
Expand Down Expand Up @@ -119,9 +124,13 @@ private void analyzeFrame(Frame frame) throws Exception {
} else if (frame instanceof PingFrame) {

PingFrame pingFrame = (PingFrame) frame;
controlFrames.add(pingFrame);
// Logging.log("Ping:" + pingFrame);
// System.out.flush();
if ((pingFrame.getFlags() & 0x1) == 0) {
// Logging.log("Ping:" + pingFrame);
// System.out.flush();
Frame ack = new Frame(Frame.Type.PING, 0x1, 0, pingFrame.getPayload());
flowControlManager.getOutputStream().write(ack.toByteArray());
flowControlManager.getOutputStream().flush();
}
} else {

controlFrames.add(frame);
Expand Down Expand Up @@ -168,6 +177,7 @@ public void putToFlowControlledQueue(byte[] frameData) throws Exception {
} else {

Frame f = new Frame(frame);
remapOutgoingStreamId(f);
flowControlManager.write(f);
if (f.getType() == Frame.Type.SETTINGS) {

Expand All @@ -183,6 +193,20 @@ public void putToFlowControlledQueue(byte[] frameData) throws Exception {
}
}

private void remapOutgoingStreamId(Frame f) {
if (streamIdRemapper == null || f.getStreamId() == 0) {

return;
}
if (f.getType() == Frame.Type.HEADERS) {

f.setStreamId(streamIdRemapper.mapClientToServer(f.getStreamId(), true));
} else if (f.getType() == Frame.Type.DATA) {

f.setStreamId(streamIdRemapper.mapClientToServer(f.getStreamId(), false));
}
}

public void closeFlowControlledQueue() throws Exception {
flowControlManager.getOutputStream().close();
}
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/core/packetproxy/http2/FramesBase.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 DeNA Co., Ltd.
* Copyright 2019,2026 DeNA Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -30,10 +30,12 @@ public abstract class FramesBase {
protected FrameManager serverFrameManager = new FrameManager();
protected boolean alreadySentClientRequestPrologue = false;
protected boolean alreadySentClientRequestEpilogue = false;
private final StreamIdRemapper streamIdRemapper = new StreamIdRemapper();

public FramesBase() throws Exception {
clientFrameManager = new FrameManager();
serverFrameManager = new FrameManager();
serverFrameManager.setStreamIdRemapper(streamIdRemapper);
}

public String getName() {
Expand All @@ -49,7 +51,7 @@ public void clientRequestArrived(byte[] frames) throws Exception {
}

public void serverResponseArrived(byte[] frames) throws Exception {
serverFrameManager.write(frames);
serverFrameManager.write(streamIdRemapper.rewriteResponseToClient(frames));
}

public byte[] passThroughClientRequest() throws Exception {
Expand Down
128 changes: 128 additions & 0 deletions src/main/java/core/packetproxy/http2/StreamIdRemapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright 2026 DeNA Co., Ltd.
*
* Licensed 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.
*/
package packetproxy.http2;

import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang3.ArrayUtils;
import packetproxy.http2.frames.Frame;
import packetproxy.http2.frames.FrameUtils;

/**
* Remaps HTTP/2 stream IDs between the client-facing and the server-facing
* connection.
*
* <p>
* PacketProxy terminates the two connections independently and forwards each
* request only after it has been fully buffered (see
* {@code Http2#filterFrames}). Concurrent requests can therefore reach the
* server in a different order than the client opened them, which violates RFC
* 7540 5.1.1 ("The identifier of a newly established stream MUST be numerically
* greater than all streams that the initiating endpoint has opened") and makes
* the server abort the connection with {@code
* GOAWAY(PROTOCOL_ERROR)}.
*
* <p>
* This class assigns a fresh, monotonically increasing server stream ID to each
* client stream in the order the request is actually sent to the server, and
* maps the server's response stream IDs back to the client's. Only HEADERS and
* DATA frames carry stream IDs that need remapping here; connection-level
* frames (stream 0) and consumed control frames (WINDOW_UPDATE / RST_STREAM,
* kept in server-ID space for flow control) are left as-is.
*/
public class StreamIdRemapper {

private static final int TYPE_DATA = Frame.Type.DATA.ordinal(); // 0x0
private static final int TYPE_HEADERS = Frame.Type.HEADERS.ordinal(); // 0x1

private final Map<Integer, Integer> clientToServer = new HashMap<>();
private final Map<Integer, Integer> serverToClient = new HashMap<>();
private int nextServerStreamId = 1; // client-initiated streams are odd

/**
* Returns the server stream ID for a client stream ID, allocating the next
* increasing ID the first time a stream is opened (i.e. on its first HEADERS
* frame).
*/
public synchronized int mapClientToServer(int clientStreamId, boolean allocateIfAbsent) {
Integer serverStreamId = clientToServer.get(clientStreamId);
if (serverStreamId == null) {

if (!allocateIfAbsent) {

return clientStreamId;
}
serverStreamId = nextServerStreamId;
nextServerStreamId += 2;
clientToServer.put(clientStreamId, serverStreamId);
serverToClient.put(serverStreamId, clientStreamId);
}
return serverStreamId;
}

/**
* Maps a server stream ID back to the client stream ID it was allocated for.
*/
public synchronized int mapServerToClient(int serverStreamId) {
Integer clientStreamId = serverToClient.get(serverStreamId);
return (clientStreamId != null) ? clientStreamId : serverStreamId;
}

/**
* Rewrites the stream IDs of HEADERS/DATA frames in a server->client byte
* stream back to the client's stream IDs. The input must consist of whole
* frames.
*/
public synchronized byte[] rewriteResponseToClient(byte[] frames) throws Exception {
byte[] out = frames.clone();
int pos = 0;
while (pos < out.length) {

byte[] remaining = ArrayUtils.subarray(out, pos, out.length);
int delim = FrameUtils.checkDelimiter(remaining);
if (delim <= 0) {

break;
}
if (!FrameUtils.isPreface(remaining)) {

int type = out[pos + 3] & 0xff;
if (type == TYPE_HEADERS || type == TYPE_DATA) {

int serverStreamId = readStreamId(out, pos);
if (serverStreamId != 0) {

writeStreamId(out, pos, mapServerToClient(serverStreamId));
}
}
}
pos += delim;
}
return out;
}

private static int readStreamId(byte[] data, int frameOffset) {
return ((data[frameOffset + 5] & 0x7f) << 24) | ((data[frameOffset + 6] & 0xff) << 16)
| ((data[frameOffset + 7] & 0xff) << 8) | (data[frameOffset + 8] & 0xff);
}

private static void writeStreamId(byte[] data, int frameOffset, int streamId) {
data[frameOffset + 5] = (byte) ((data[frameOffset + 5] & 0x80) | ((streamId >>> 24) & 0x7f));
data[frameOffset + 6] = (byte) ((streamId >>> 16) & 0xff);
data[frameOffset + 7] = (byte) ((streamId >>> 8) & 0xff);
data[frameOffset + 8] = (byte) (streamId & 0xff);
}
}
89 changes: 89 additions & 0 deletions src/test/java/packetproxy/http2/FrameManagerStreamIdTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2026 DeNA Co., Ltd.
*
* Licensed 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.
*/
package packetproxy.http2;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.List;
import org.junit.jupiter.api.Test;
import packetproxy.http2.frames.Frame;
import packetproxy.http2.frames.FrameUtils;

public class FrameManagerStreamIdTest {

private byte[] headersFrame(int streamId) {
byte[] payload = new byte[]{(byte) 0x82}; // arbitrary HPACK bytes, opaque here
ByteBuffer bb = ByteBuffer.allocate(9 + payload.length);
bb.put((byte) 0).put((byte) 0).put((byte) payload.length);
bb.put((byte) Frame.Type.HEADERS.ordinal());
bb.put((byte) 0x04); // END_HEADERS
bb.putInt(streamId);
bb.put(payload);
return bb.array();
}

private byte[] readAvailable(InputStream in) throws Exception {
int n = in.available();
byte[] buf = new byte[n];
int read = 0;
while (read < n) {

read += in.read(buf, read, n - read);
}
return buf;
}

/*
* Reproduces the GOAWAY(PROTOCOL_ERROR) scenario at the FrameManager wiring level:
* requests reach the server-facing queue out of client order (25 then 23), and the
* remapper must emit HEADERS to the server with strictly increasing stream IDs.
*/
@Test
public void outgoingHeadersGetIncreasingServerStreamIds() throws Exception {
FrameManager serverFm = new FrameManager();
serverFm.setStreamIdRemapper(new StreamIdRemapper());

serverFm.putToFlowControlledQueue(headersFrame(25));
serverFm.putToFlowControlledQueue(headersFrame(23));

byte[] out = readAvailable(serverFm.getFlowControlledInputStream());
List<Frame> frames = FrameUtils.parseFrames(out);

assertEquals(2, frames.size());
assertEquals(Frame.Type.HEADERS, frames.get(0).getType());
assertEquals(Frame.Type.HEADERS, frames.get(1).getType());
// Client sent 25 first then 23; on the server wire they must be increasing.
assertEquals(1, frames.get(0).getStreamId());
assertEquals(3, frames.get(1).getStreamId());
}

/* Without a remapper (client-facing FrameManager), stream IDs pass through unchanged. */
@Test
public void withoutRemapperStreamIdsUnchanged() throws Exception {
FrameManager fm = new FrameManager();

fm.putToFlowControlledQueue(headersFrame(25));
fm.putToFlowControlledQueue(headersFrame(23));

byte[] out = readAvailable(fm.getFlowControlledInputStream());
List<Frame> frames = FrameUtils.parseFrames(out);

assertEquals(25, frames.get(0).getStreamId());
assertEquals(23, frames.get(1).getStreamId());
}
}
71 changes: 71 additions & 0 deletions src/test/java/packetproxy/http2/PingFrameHandlingTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright 2026 DeNA Co., Ltd.
*
* Licensed 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.
*/
package packetproxy.http2;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.InputStream;
import org.apache.commons.codec.binary.Hex;
import org.junit.jupiter.api.Test;

public class PingFrameHandlingTest {

private byte[] readAvailable(InputStream in) throws Exception {
int n = in.available();
byte[] buf = new byte[n];
int read = 0;
while (read < n) {

read += in.read(buf, read, n - read);
}
return buf;
}

/*
* A PING without the ACK flag must be answered on the same connection with a PING
* carrying the ACK flag and identical payload, and must not be relayed as a control
* frame to the other connection (RFC 7540 6.7).
*/
@Test
public void pingIsAnsweredLocallyWithAck() throws Exception {
FrameManager fm = new FrameManager();
// length=8, type=PING(0x06), flags=0x00, streamId=0, payload=1122334455667788
fm.write(Hex.decodeHex("0000080600000000001122334455667788".toCharArray()));

// The PING is not forwarded to the peer connection.
assertTrue(fm.readControlFrames().isEmpty());

// A PING+ACK with the identical payload is sent back to the origin connection.
byte[] reply = readAvailable(fm.getFlowControlledInputStream());
assertArrayEquals(Hex.decodeHex("0000080601000000001122334455667788".toCharArray()), reply);
}

/*
* A PING with the ACK flag is a response to a PING PacketProxy never sends, so it is
* dropped: neither relayed nor answered.
*/
@Test
public void pingAckIsDropped() throws Exception {
FrameManager fm = new FrameManager();
// flags=0x01 (ACK)
fm.write(Hex.decodeHex("0000080601000000009988776655443322".toCharArray()));

assertTrue(fm.readControlFrames().isEmpty());
assertEquals(0, fm.getFlowControlledInputStream().available());
}
}
Loading