Skip to content

Capture upload request bodies in PulseProxy - #383

Draft
ffittschen wants to merge 3 commits into
kean:mainfrom
ffittschen:feat/proxy-capture-upload-bodies
Draft

ffittschen wants to merge 3 commits into
kean:mainfrom
ffittschen:feat/proxy-capture-upload-bodies

Conversation

@ffittschen

Copy link
Copy Markdown

This PR logs the request bodies of two kinds of uploads:

  • uploads from data
  • uploads that stream their body through the delegate

The second includes every request with a body sent by swift-openapi-urlsession, which streams by default.

It builds on #381, because URLSessionProxy.upload(for:from:delegate:) only sees the created task once the delegate is passed, so the diff includes that commit. The other two commits can be reviewed separately.

1. Uploads from data

URLSession keeps the data passed to uploadTask(with:from:) out of originalRequest, while NetworkLogger reads originalRequest.httpBody. So every upload from data was logged without its body. PulseProxy now swizzles the uploadTaskWithRequest:fromData: variants, and URLSessionProxy attaches the body itself. Both attach it as URLSessionTask.pulse_requestBody (a package property), which is read after httpBody.

2. Streamed uploads

Tasks created with uploadTask(withStreamedRequest:) get their body from urlSession(_:task:needNewBodyStream:). PulseProxy now hooks that method on the delegate class that implements it, once per class, and tees the returned stream on one shared thread. The body is attached only once the stream has been read to the end, so a stored body is never partial.

Bodies of either kind over LoggerStore.Configuration.responseBodySizeLimit aren't attached or stored, and the upload itself is untouched. Attached bodies go through sensitiveDataFields like any other.

Part of #378.

How to test

Use an iOS app that calls NetworkLogger.enableProxy() at launch, e.g. PulseGRPC's example app, and send the uploads to https://httpbin.org/post, which echoes the body it received under "data".

  1. Upload JSON with URLSession.shared.upload(for:from:). Open the console. Assert that the POST shows its request body, and that the response echoes the same body.
  2. Repeat with uploadTask(with:from:) and uploadTask(with:from:completionHandler:). Assert the same for each.
  3. Send a POST through swift-openapi-urlsession's default transport, or through uploadTask(withStreamedRequest:) with a delegate that returns an InputStream from urlSession(_:task:needNewBodyStream:). Assert that the console shows the request body, and that the response echoes it intact.
  4. Upload a body larger than 8 MB, the default size limit. Assert that the upload succeeds and that the console shows no request body.

Limitations

  • uploadTask(with:fromFile:) bodies aren't attached, because files can be large.
  • The cancelAll() in the _didFinishWithError: hook is a safety net that none of the traced cases needed. I'm happy to drop it if you prefer.
  • Pre-existing and unchanged: a streamed upload that's cancelled before its delegate provides the stream stays pending in the console.

URLSessionProxy.upload(for:from:delegate:) and
upload(for:fromFile:delegate:) create a URLSessionProxyDelegate that
wraps the caller's delegate, but never pass it to URLSession:
- the caller's task delegate was silently ignored, so it got no
  authentication challenges or progress callbacks;
- the proxy never saw the created task, so it never logged the
  completion: the upload stayed pending in the console, without its
  response.

Pass the delegate, like data(for:delegate:) and
download(for:delegate:) already do.
URLSession keeps the body passed to uploadTask(with:from:) out of the
task's originalRequest, so NetworkLogger, which reads
originalRequest.httpBody, logged these uploads without a request body
on every path.

Attach the body to the task as an associated object
(URLSessionTask.pulse_requestBody), which _logTask reads after httpBody:
- PulseProxy swizzles uploadTaskWithRequest:fromData:, with and without
  a completion handler, and the private
  _uploadTaskWithRequest:fromData:delegate:completionHandler: used by
  upload(for:from:delegate:);
- URLSessionProxy attaches the body in uploadTask(with:from:),
  uploadTask(with:from:completionHandler:) and, through
  URLSessionProxyDelegate, upload(for:from:delegate:).
Tasks created with uploadTask(withStreamedRequest:) get their body from
the delegate's urlSession(_:task:needNewBodyStream:), so PulseProxy
logged them without a request body. This includes every request with a
body sent by swift-openapi-urlsession, which streams by default.

When an upload task without a body resumes, PulseProxy now hooks
needNewBodyStream: and needNewBodyStreamFromOffset:completionHandler:
on the class that implements them, once per class, and tees the stream
the delegate returns into a bound stream pair while recording the
bytes. The recorded body is attached to the task before the stream
closes, so it's there when the task completes.

- Covers the task delegate and the session delegate (read through the
  task's private "session" key), delegates that forward through
  forwardingTarget(for:), such as URLSessionProxyDelegate, and
  inherited implementations, which are hooked on the class that
  defines them.
- A redirect or an authentication retry asks for a new stream. The
  body of the latest attempt that was read to the end wins; an attempt
  that is cut off never replaces it, so a body is never partial. A
  resumed upload (from an offset) reuses the prefix read by the
  previous attempt.
- Bodies over the store's responseBodySizeLimit aren't kept.
- All tees run on one shared thread driven by a run loop and never
  block. The tees of a task that URLSession abandons are closed from
  the _didFinishWithError: hook, and a stream that a delegate returns
  after the task ended is passed through untouched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant