fix(repeater): send request bodies to libcurl as raw bytes - #769
Conversation
`applyCurlBody` passed the decoded body to `CURLOPT_POSTFIELDS` as a JS string (`bodyBuffer.toString()`). That is a lossy UTF-8 decode: every byte that is not part of a valid UTF-8 sequence became U+FFFD, and the native binding re-encoded each of those as `EF BF BD`. `POSTFIELDSIZE` was then set to the original length, so only the first `length` bytes of the ~2x longer buffer went out — the request still succeeded with a matching `Content-Length` while the target received different bytes. Any repeater request whose body carried bytes that do not form valid UTF-8 was silently corrupted: binary uploads, gzip/deflate bodies, protobuf, DER, encrypted blobs. Text that is valid UTF-8 survives the decode and re-encode round trip unharmed. Measured end to end, a 10 MB `0x00..0xFF` body diverged at offset 128 with 7,802,880 of 10,485,760 bytes wrong. The body now reaches libcurl through `UPLOAD` + `INFILESIZE_LARGE` + `READFUNCTION`, which copies from the buffer verbatim. A `SEEKFUNCTION` is registered alongside it so libcurl can replay the body when a request is sent twice (authentication negotiation) — a read callback without a seek callback is reported as non-seekable and the replay fails. `transformScript` was lossy in the same way and additionally dropped `encoding` (`Request.toJSON()` does not carry it), which re-encoded the already-substituted body a second time. The original body and encoding are now restored whenever the script leaves the body untouched. This is a regression introduced in 5857011 ("refactor(repeater): add custom HTTP client (#744)"), first shipped in v13.11.0-next.5 and first stable in v13.12.0. Releases up to v13.11.0 wrote the body as a Buffer via `req.end(iconv.encode(body, encoding))` and were byte-exact. Note one intentional behaviour change: libcurl adds `Content-Type: application/x-www-form-urlencoded` by default for a `POSTFIELDS` body but not for the upload path, so that default header no longer appears on bodies sent without an explicit `Content-Type`. This restores the pre-v13.12.0 behaviour, which wrote to a raw node socket and added no default either. Caller-supplied `Content-Type` is unaffected. Verified unchanged on the upload path: no `Expect: 100-continue`, no `Transfer-Encoding`, the request method is preserved for POST, PUT, PATCH, DELETE and GET, and a caller-supplied `Content-Length` still overrides the body length without being duplicated, so request smuggling payloads keep working. Adds a `request body` suite of 23 tests: byte-exactness for 4 KiB and 256 KiB `0x00..0xFF` bodies, multi-byte UTF-8 verbatim, `Content-Length` equal to the decoded byte length, the header behaviour above, absent and zero-byte bodies, byte-exactness across virtual scripts that leave the body alone, rewrite it, or touch only headers, and the read/seek callback contract including a byte-for-byte replay after seeking back to the start. Restoring the two previous lines fails 15 of the 23.
There was a problem hiding this comment.
Pull request overview
This PR fixes a regression in the HTTP repeater where request bodies containing non‑UTF8 bytes could be silently corrupted when passed to libcurl as a JS string. It switches the body write path to a binary-safe libcurl upload callback and adjusts virtual-script body transformation to preserve the original body/encoding when scripts do not modify the body.
Changes:
- Replaced
CURLOPT_POSTFIELDSusage withUPLOAD+INFILESIZE_LARGE+READFUNCTION(and addedSEEKFUNCTION) to send bodies as raw bytes. - Updated
transformScriptso the original body+encoding are restored when a virtual script leaves the body unchanged. - Added extensive unit tests validating byte-exact body forwarding, header behavior, method preservation, and correct libcurl callback behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/RequestExecutor/HttpRequestExecutor.ts | Switches libcurl body handling to upload callbacks for binary safety; updates virtual-script body restoration logic. |
| src/RequestExecutor/HttpRequestExecutor.spec.ts | Adds a comprehensive “request body” test suite covering binary integrity, headers, methods, and upload callback replay/seek behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The new `should not duplicate or override a caller-supplied Content-Length` case takes the raw `startServer()` path, which returns a fixture the caller has to close — unlike the HTTP path, it is not registered for teardown in `afterEach`. Every other test on that path closes it; this one did not, so the leaked listening socket kept the event loop alive and jest hung after the run instead of exiting. Reproduced locally: the leaky version exits 124 under a timeout with "Jest did not exit one second after the test run has completed", and the CI `test:unit` job sat in progress for ten minutes while lint, format and build finished in thirty seconds.
`transformScript` decided the body was untouched by comparing it against the decoded view alone, so a virtual script that set `encoding` without rewriting `body` had that encoding replaced with the original request's. Since `Request.toJSON()` does not carry `encoding`, a script can only ever add one, and adding one is an explicit instruction to decode its body before sending — dropping it sent the literal text instead. The restore now applies only when the script left `encoding` unset, which is what an untouched round trip looks like. Adds a case where the request carries no encoding, the body is base64 text and the script only flags `encoding: 'base64'`: the target must receive the decoded bytes. It fails without the guard. Reported by Copilot review on #769.
|
Regression fix for v13.12.0, based on Problem
curl.setOpt('POSTFIELDS', bodyBuffer.toString()); // lossy UTF-8 decode
curl.setOpt('POSTFIELDSIZE', bodyBuffer.length); // truncates the expanded buffer
Any body carrying bytes that do not form valid UTF-8 was affected: binary uploads, gzip/deflate bodies, protobuf, DER, encrypted blobs. Text that is valid UTF-8 survives the round trip unharmed, so reproducing with emoji proves nothing — use a
Affected versions
Introduced by 5857011 (refactor(repeater): add custom HTTP client #744). Fix
Behaviour change to notelibcurl adds This restores the pre-v13.12.0 behaviour: the old node Regression checksVerified against the binding and pinned by tests:
Test coverage
Counterfactual: restoring the two Follow-ups, deliberately not in this PR
|
shevchenkov-neuralegion
left a comment
There was a problem hiding this comment.
Just suggestions I think might be valid. Feel free to reject.
relates-to: #744