Join HTTP request and reponse tabs

This commit is contained in:
emanuele-f 2022-04-11 11:52:49 +02:00
parent 64ae11f6f4
commit 64721ea64c
6 changed files with 111 additions and 98 deletions

View File

@ -48,6 +48,7 @@ public class HTTPReassembly {
private final ReassemblyListener mListener;
private boolean mReassembleChunks;
private boolean mInvalidHttp;
private boolean mIsTx;
public HTTPReassembly(boolean reassembleChunks, ReassemblyListener listener) {
mListener = listener;
@ -80,6 +81,10 @@ public class HTTPReassembly {
void onChunkReassembled(PayloadChunk chunk);
}
private void log_d(String msg) {
Log.d(TAG + "(" + (mIsTx ? "TX" : "RX") + ")", msg);
}
/* The request/response tab shows reassembled HTTP chunks.
* Reassembling chunks is requires when using a content-encoding like gzip since we can only
* decode the data when we have the full chunk and we cannot determine data bounds.
@ -89,6 +94,7 @@ public class HTTPReassembly {
int body_start = 0;
byte[] payload = chunk.payload;
boolean chunked_complete = false;
mIsTx = chunk.is_sent;
if(mReadingHeaders) {
// Reading the HTTP headers
@ -100,11 +106,11 @@ public class HTTPReassembly {
String line = reader.readLine();
while((line != null) && (line.length() > 0)) {
line = line.toLowerCase();
//Log.d(TAG, "[HEADER] " + line);
//log_d("[HEADER] " + line);
if(line.startsWith("content-encoding: ")) {
String contentEncoding = line.substring(18);
Log.d(TAG, "Content-Encoding: " + contentEncoding);
log_d("Content-Encoding: " + contentEncoding);
switch (contentEncoding) {
case "gzip":
@ -121,17 +127,17 @@ public class HTTPReassembly {
}
} else if(line.startsWith("content-type: ")) {
String contentType = line.substring(14);
Log.d(TAG, "Content-Type: " + contentType);
log_d("Content-Type: " + contentType);
} else if(line.startsWith("content-length: ")) {
try {
int contentLength = Integer.parseInt(line.substring(16));
Log.d(TAG, "Content-Length: " + contentLength);
log_d("Content-Length: " + contentLength);
} catch (NumberFormatException ignored) {}
} else if(line.startsWith("upgrade: ")) {
Log.d(TAG, "Upgrade found, stop parsing");
log_d("Upgrade found, stop parsing");
mReassembleChunks = false;
} else if(line.equals("transfer-encoding: chunked")) {
Log.d(TAG, "Detected chunked encoding");
log_d("Detected chunked encoding");
mChunkedEncoding = true;
}
@ -145,6 +151,8 @@ public class HTTPReassembly {
mHeaders.add(chunk.subchunk(0, body_start));
} else {
if(mHeadersSize > MAX_HEADERS_SIZE) {
log_d("Assuming not HTTP");
// Assume this is not valid HTTP traffic
mReadingHeaders = false;
mReassembleChunks = false;
@ -159,8 +167,10 @@ public class HTTPReassembly {
// If not Content-Length provided and not using chunked encoding, then we cannot determine
// chunks bounds, so disable reassembly
if(!mReadingHeaders && (mContentLength < 0) && (!mChunkedEncoding))
if(!mReadingHeaders && (mContentLength < 0) && (!mChunkedEncoding) && mReassembleChunks) {
log_d("Cannot determine bounds, disable reassembly");
mReassembleChunks = false;
}
// When mReassembleChunks is false, each chunk should be passed to the mListener
if(!mReassembleChunks)
@ -181,7 +191,7 @@ public class HTTPReassembly {
body_start += line.length() + 2;
body_size -= line.length() + 2;
Log.d(TAG, "Chunk length: " + mContentLength);
log_d("Chunk length: " + mContentLength);
if(mContentLength == 0)
chunked_complete = true;
@ -193,6 +203,7 @@ public class HTTPReassembly {
// NOTE: Content-Length is optional in HTTP/2.0, mitmproxy reconstructs the entire message
if(body_size > 0) {
if(mContentLength > 0) {
//log_d("body: " + body_size + " / " + mContentLength);
if(body_size < mContentLength)
mContentLength -= body_size;
else {
@ -215,11 +226,14 @@ public class HTTPReassembly {
if(chunked_complete || !mReassembleChunks)
mChunkedEncoding = false;
if(((mContentLength <= 0) || !mReassembleChunks) && !mChunkedEncoding) {
if(((mContentLength <= 0) || !mReassembleChunks)
&& !mChunkedEncoding) {
// Reassemble the chunks (NOTE: gzip is applied only after all the chunks are collected)
PayloadChunk headers = reassembleChunks(mHeaders);
PayloadChunk body = mBody.size() > 0 ? reassembleChunks(mBody) : null;
//log_d("mContentLength=" + mContentLength + ", mReassembleChunks=" + mReassembleChunks + ", mChunkedEncoding=" + mChunkedEncoding);
// Decode body
if((body != null) && (mContentEncoding != ContentEncoding.UNKNOWN))
decodeBody(body);
@ -245,7 +259,7 @@ public class HTTPReassembly {
if((new_body_start > 0) && (chunk.payload.length > new_body_start)) {
// Part of this chunk should be processed as a new chunk
Log.d(TAG, "Continue from " + new_body_start);
log_d("Continue from " + new_body_start);
handleChunk(chunk.subchunk(new_body_start, chunk.payload.length - new_body_start));
}
}
@ -254,7 +268,7 @@ public class HTTPReassembly {
private void decodeBody(PayloadChunk body) {
InputStream inputStream = null;
//Log.d(TAG, "Decoding as " + mContentEncoding.name().toLowerCase());
//log_d("Decoding as " + mContentEncoding.name().toLowerCase());
try(ByteArrayInputStream bis = new ByteArrayInputStream(body.payload)) {
switch (mContentEncoding) {
@ -282,7 +296,7 @@ public class HTTPReassembly {
}
}
} catch (IOException ignored) {
Log.d(TAG, mContentEncoding.name().toLowerCase() + " decoding failed");
log_d(mContentEncoding.name().toLowerCase() + " decoding failed");
//ignored.printStackTrace();
} finally {
Utils.safeClose(inputStream);

View File

@ -55,14 +55,14 @@ public class ConnectionDetailsActivity extends BaseActivity implements Connectio
private int mCurChunks;
private boolean mListenerSet;
private boolean mHasPayload;
private boolean mHasRequestTab;
private boolean mHasResponseTab;
private boolean mHasHttpTab;
private boolean mHasWsTab;
private final ArrayList<ConnUpdateListener> mListeners = new ArrayList<>();
private static final int POS_OVERVIEW = 0;
private static final int POS_REQUEST = 1;
private static final int POS_RESPONSE = 2;
private static final int POS_PAYLOAD = 3;
private static final int POS_WEBSOCKET = 1;
private static final int POS_HTTP = 2;
private static final int POS_RAW_PAYLOAD = 3;
public interface ConnUpdateListener {
void connectionUpdated();
@ -134,12 +134,12 @@ public class ConnectionDetailsActivity extends BaseActivity implements Connectio
int pos = getVisibleTabsPositions()[position];
switch (pos) {
case POS_REQUEST:
return ConnectionPayload.newInstance(ConnectionPayload.Direction.REQUEST_ONLY);
case POS_RESPONSE:
return ConnectionPayload.newInstance(ConnectionPayload.Direction.RESPONSE_ONLY);
case POS_PAYLOAD:
return ConnectionPayload.newInstance(ConnectionPayload.Direction.BOTH);
case POS_WEBSOCKET:
return ConnectionPayload.newInstance(PayloadChunk.ChunkType.WEBSOCKET);
case POS_HTTP:
return ConnectionPayload.newInstance(PayloadChunk.ChunkType.HTTP);
case POS_RAW_PAYLOAD:
return ConnectionPayload.newInstance(PayloadChunk.ChunkType.RAW);
case POS_OVERVIEW:
default:
return new ConnectionOverview();
@ -147,18 +147,18 @@ public class ConnectionDetailsActivity extends BaseActivity implements Connectio
}
@Override
public int getItemCount() { return 1 + (mHasPayload ? 1 : 0) + (mHasRequestTab ? 1 : 0) + (mHasResponseTab ? 1 : 0); }
public int getItemCount() { return 1 + (mHasPayload ? 1 : 0) + (mHasHttpTab ? 1 : 0) + (mHasWsTab ? 1 : 0); }
public int getPageTitle(final int position) {
int pos = getVisibleTabsPositions()[position];
switch (pos) {
case POS_REQUEST:
return R.string.request;
case POS_RESPONSE:
return R.string.response;
case POS_PAYLOAD:
return R.string.payload;
case POS_WEBSOCKET:
return R.string.websocket;
case POS_HTTP:
return R.string.http;
case POS_RAW_PAYLOAD:
return R.string.raw_payload;
case POS_OVERVIEW:
default:
return R.string.overview;
@ -171,12 +171,12 @@ public class ConnectionDetailsActivity extends BaseActivity implements Connectio
visible[i++] = POS_OVERVIEW;
if(mHasRequestTab)
visible[i++] = POS_REQUEST;
if(mHasResponseTab)
visible[i++] = POS_RESPONSE;
if(mHasWsTab)
visible[i++] = POS_WEBSOCKET;
if(mHasHttpTab)
visible[i++] = POS_HTTP;
if(mHasPayload)
visible[i] = POS_PAYLOAD;
visible[i] = POS_RAW_PAYLOAD;
return visible;
}
@ -254,7 +254,7 @@ public class ConnectionDetailsActivity extends BaseActivity implements Connectio
@SuppressLint("NotifyDataSetChanged")
private void recheckTabs() {
if(mHasRequestTab && mHasResponseTab)
if(mHasHttpTab && mHasWsTab)
return;
int max_check = Math.min(mConn.getNumPayloadChunks(), MAX_CHUNKS_TO_CHECK);
@ -268,14 +268,12 @@ public class ConnectionDetailsActivity extends BaseActivity implements Connectio
for(int i=mCurChunks; i<max_check; i++) {
PayloadChunk chunk = mConn.getPayloadChunk(i);
if((chunk.type == PayloadChunk.ChunkType.HTTP) || (chunk.type == PayloadChunk.ChunkType.WEBSOCKET)) {
if(chunk.is_sent && !mHasRequestTab) {
mHasRequestTab = true;
changed = true;
} else if(!chunk.is_sent && !mHasResponseTab) {
mHasResponseTab = true;
changed = true;
}
if(!mHasHttpTab && (chunk.type == PayloadChunk.ChunkType.HTTP)) {
mHasHttpTab = true;
changed = true;
} else if (!mHasWsTab && (chunk.type == PayloadChunk.ChunkType.WEBSOCKET)) {
mHasWsTab = true;
changed = true;
}
}

View File

@ -34,7 +34,6 @@ import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.HTTPReassembly;
import com.emanuelef.remote_capture.R;
import com.emanuelef.remote_capture.Utils;
import com.emanuelef.remote_capture.fragments.ConnectionPayload.Direction;
import com.emanuelef.remote_capture.model.ConnectionDescriptor;
import com.emanuelef.remote_capture.model.PayloadChunk;
import com.emanuelef.remote_capture.model.PayloadChunk.ChunkType;
@ -56,19 +55,25 @@ public class PayloadAdapter extends RecyclerView.Adapter<PayloadAdapter.PayloadV
private final LayoutInflater mLayoutInflater;
private final ConnectionDescriptor mConn;
private final Context mContext;
private final Direction mDir;
private final ChunkType mMode;
private int mHandledChunks;
private final ArrayList<AdapterChunk> mChunks = new ArrayList<>();
private final HTTPReassembly mHttp;
private final HTTPReassembly mHttpReq;
private final HTTPReassembly mHttpRes;
public PayloadAdapter(Context context, ConnectionDescriptor conn, Direction dir) {
public PayloadAdapter(Context context, ConnectionDescriptor conn, ChunkType mode) {
mLayoutInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
mConn = conn;
mContext = context;
mDir = dir;
mMode = mode;
// Note: in minimal mode, only the first chunk is captured, so don't reassemble them
mHttp = new HTTPReassembly(CaptureService.getCurPayloadMode() == Prefs.PayloadMode.FULL, this);
boolean reassemble = (CaptureService.getCurPayloadMode() == Prefs.PayloadMode.FULL);
// each direction must have its separate reassembly
mHttpReq = new HTTPReassembly(reassemble, this);
mHttpRes = new HTTPReassembly(reassemble, this);
handleChunksAdded(mConn.getNumPayloadChunks());
}
@ -103,7 +108,7 @@ public class PayloadAdapter extends RecyclerView.Adapter<PayloadAdapter.PayloadV
private void makeText() {
int dump_len = mIsExpanded ? mChunk.payload.length : Math.min(mChunk.payload.length, COLLAPSE_CHUNK_SIZE);
if(isPayloadTab())
if(mMode == ChunkType.RAW)
mTheText = Utils.hexdump(mChunk.payload, 0, dump_len);
else
mTheText = new String(mChunk.payload, 0, dump_len, StandardCharsets.UTF_8);
@ -203,40 +208,38 @@ public class PayloadAdapter extends RecyclerView.Adapter<PayloadAdapter.PayloadV
if(page.adaptChunk.isExpanded()) {
int numPages = page.adaptChunk.getNumPages();
int firstPagePos = pos - (numPages - 1);
page.adaptChunk.collapse();
notifyItemRangeRemoved(pos - (numPages - 1), numPages - 1);
notifyItemChanged(firstPagePos);
notifyItemRangeRemoved(firstPagePos + 1, numPages - 1);
} else {
page.adaptChunk.expand();
notifyItemChanged(pos);
notifyItemRangeInserted(pos + 1, page.adaptChunk.getNumPages() - 1);
}
notifyItemChanged(pos);
});
return holder;
}
private String getHeaderTag(PayloadChunk chunk) {
if(mMode == ChunkType.HTTP)
return (chunk.is_sent) ? mContext.getString(R.string.request) : mContext.getString(R.string.response);
else
return chunk.is_sent ? mContext.getString(R.string.tx_direction) : mContext.getString(R.string.rx_direction);
}
@Override
public void onBindViewHolder(@NonNull PayloadViewHolder holder, int position) {
Page page = getItem(position);
Locale locale = Utils.getPrimaryLocale(mContext);
String prefix = "";
PayloadChunk chunk = page.adaptChunk.getPayloadChunk();
if(page.isFirst()) {
// Show the header for the first page
if(!isPayloadTab()) {
// NOTE: do not add the prefix in the "Payload" tab, as the chunk is not analyzed by the
// HTTPReassembly
if(chunk.type == ChunkType.HTTP)
prefix = "HTTP_";
else if(chunk.type == ChunkType.WEBSOCKET)
prefix = "WS_";
}
Locale locale = Utils.getPrimaryLocale(mContext);
holder.header.setText(String.format(locale,
"#%d [%s%s] %s — %s", page.adaptChunk.originalPos + 1,
prefix, chunk.is_sent ? "TX" : "RX",
"#%d [%s] %s — %s", page.adaptChunk.originalPos + 1,
getHeaderTag(chunk),
(new SimpleDateFormat("HH:mm:ss.SSS", locale)).format(new Date(chunk.timestamp)),
Utils.formatBytes(chunk.payload.length)));
holder.header.setVisibility(View.VISIBLE);
@ -295,24 +298,24 @@ public class PayloadAdapter extends RecyclerView.Adapter<PayloadAdapter.PayloadV
return mChunks.get(i).getPage(pageIdx);
}
private boolean isPayloadTab() {
return(mDir == Direction.BOTH);
}
public void handleChunksAdded(int tot_chunks) {
for(int i = mHandledChunks; i<tot_chunks; i++) {
PayloadChunk chunk = mConn.getPayloadChunk(i);
if((mDir == Direction.BOTH) ||
(mDir == Direction.REQUEST_ONLY && chunk.is_sent) ||
(mDir == Direction.RESPONSE_ONLY && !chunk.is_sent)) {
if(!isPayloadTab() && (chunk.type == ChunkType.HTTP))
mHttp.handleChunk(chunk); // will call onChunkReassembled
else {
int insert_pos = getItemCount();
mChunks.add(new AdapterChunk(chunk, mChunks.size()));
notifyItemInserted(insert_pos);
}
// Exclude unrelated chunks
if((mMode != ChunkType.RAW) && (mMode != chunk.type))
continue;
if(mMode == ChunkType.HTTP) {
// will call onChunkReassembled
if(chunk.is_sent)
mHttpReq.handleChunk(chunk);
else
mHttpRes.handleChunk(chunk);
} else {
int insert_pos = getItemCount();
mChunks.add(new AdapterChunk(chunk, mChunks.size()));
notifyItemInserted(insert_pos);
}
}

View File

@ -34,10 +34,9 @@ import com.emanuelef.remote_capture.R;
import com.emanuelef.remote_capture.activities.ConnectionDetailsActivity;
import com.emanuelef.remote_capture.adapters.PayloadAdapter;
import com.emanuelef.remote_capture.model.ConnectionDescriptor;
import com.emanuelef.remote_capture.model.PayloadChunk;
import com.emanuelef.remote_capture.views.EmptyRecyclerView;
import java.io.Serializable;
public class ConnectionPayload extends Fragment implements ConnectionDetailsActivity.ConnUpdateListener {
private ConnectionDetailsActivity mActivity;
private ConnectionDescriptor mConn;
@ -45,16 +44,10 @@ public class ConnectionPayload extends Fragment implements ConnectionDetailsActi
private TextView mTruncatedWarning;
private int mCurChunks;
public enum Direction implements Serializable {
REQUEST_ONLY,
RESPONSE_ONLY,
BOTH
}
public static ConnectionPayload newInstance(Direction dir) {
public static ConnectionPayload newInstance(PayloadChunk.ChunkType mode) {
ConnectionPayload fragment = new ConnectionPayload();
Bundle args = new Bundle();
args.putSerializable("direction", dir);
args.putSerializable("mode", mode);
fragment.setArguments(args);
return fragment;
}
@ -83,11 +76,11 @@ public class ConnectionPayload extends Fragment implements ConnectionDetailsActi
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
Bundle args = getArguments();
Direction dir;
if((args != null) && args.containsKey("direction"))
dir = (Direction) args.getSerializable("direction");
PayloadChunk.ChunkType mode;
if((args != null) && args.containsKey("mode"))
mode = (PayloadChunk.ChunkType) args.getSerializable("mode");
else
dir = Direction.BOTH;
mode = PayloadChunk.ChunkType.RAW;
EmptyRecyclerView recyclerView = view.findViewById(R.id.payload);
EmptyRecyclerView.MyLinearLayoutManager layoutMan = new EmptyRecyclerView.MyLinearLayoutManager(requireContext());
@ -98,7 +91,7 @@ public class ConnectionPayload extends Fragment implements ConnectionDetailsActi
if(mConn.isPayloadTruncated())
mTruncatedWarning.setVisibility(View.VISIBLE);
mAdapter = new PayloadAdapter(requireContext(), mConn, dir);
mAdapter = new PayloadAdapter(requireContext(), mConn, mode);
mCurChunks = mConn.getNumPayloadChunks();
recyclerView.setAdapter(mAdapter);
}

View File

@ -28,7 +28,8 @@ public class PayloadChunk implements Serializable {
public long timestamp;
public ChunkType type;
public enum ChunkType {
// Serializable need in ConnectionPayload fragment
public enum ChunkType implements Serializable {
RAW,
HTTP,
WEBSOCKET

View File

@ -299,7 +299,6 @@
<string name="payload_mode_none">The connections payload will not be shown</string>
<string name="payload_mode_minimal">Only the initial bytes of the payload will be shown</string>
<string name="payload_mode_full">The full payload will be shown (e.g. the full HTTP request and response). This requires a lot of memory</string>
<string name="payload">Payload</string>
<string name="connection">Connection</string>
<string name="encrypted">Encrypted</string>
<string name="cleartext">Cleartext</string>
@ -310,4 +309,9 @@
<string name="string_http_response">HTTP response</string>
<string name="payload_visualization">Payload visualization</string>
<string name="payload_truncated">Only a portion of the actual payload is shown, see \"%1$s\"</string>
<string name="websocket">WebSocket</string>
<string name="http">HTTP</string>
<string name="raw_payload">Raw</string>
<string name="tx_direction">TX</string>
<string name="rx_direction">RX</string>
</resources>