Harden the server-mode transport read path - #10346
Conversation
Follow-up to the Content-Length byte/char fix (#10297). Reject a malformed Content-Length instead of letting it escape the read loop. A negative length reached ArrayPool.Rent (new byte[] on net462) and surfaced as ArgumentOutOfRangeException/OverflowException, neither of which the SocketException/IOException filter in ReadAsync catches, so a single bad header tore down the connection with an unrelated exception type. An unparseable length was quieter but also wrong: int.TryParse leaves the value at 0 on failure, so a malformed header was read as a valid empty body and then failed later as a JSON parse error pointing at the payload rather than at the header. Both now report a lost connection, which is what every other malformed-frame path already does. Deserialize the frame body straight from its UTF-8 bytes. Content-Length counts bytes and System.Text.Json parses bytes, but the read path decoded to chars only for JsonDocument.Parse(ReadOnlyMemory<char>) to transcode them back, so every frame paid two conversions and an extra pooled rent on the hottest read path. This also retires the note on IMessageFormatter asking for exactly this, and removes a ReadOnlyMemory<char> parameter named utf8Json - the same byte/char ambiguity #10297 fixed, one layer down. JsonDocument.Parse(ReadOnlyMemory<byte>) does not copy its input, so the pooled buffer must outlive the document. It does, and the requirement is not new: the char overload rents its own byte array and frees it on dispose, so full materialization before dispose was already required. Documented on both sides. Separate the header-buffer growth argument from the Content-Length one. They are not equivalent - a body is legitimately unbounded, a header line is not - so grouping them overstated the guarantee. Tests: cover every malformed Content-Length shape (verified failing without the guard on net8.0 and net462), stop ConnectedHandlers leaking a listener and a half-open socket when connect/accept throws, and make the BOM test ASCII-only so it can only fail for the reason it exists. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0614f13d-ea43-40b4-b541-9058fdfd87e1
d45cede
into
nohwnd-mtp-client-source-package
🧪 Test quality grade — PR #1034634 new test methods across 4 new files. 24 A-grade, 6 B-grade, 4 D-grade, no failures. The dominant issue is four unit tests that use
This advisory comment was generated automatically. Grades are heuristic Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
Follow-up to #10297, from findings raised reviewing it. Based on
nohwnd-mtp-client-source-package(where #10297 landed), notmain.Three of these are behavioral; the rest are comments and test hygiene.
1. A malformed
Content-Lengthtore down the read loopThe comment #10297 added says
commandSizeis trusted unbounded, and that reasoning is right for large values. But negative and unparseable values aren't just unbounded — they escape.Probed against the merged code:
Two distinct defects:
Negative —
int.TryParsehappily yields-5, theis -1guard doesn't catch it, andArrayPool.Rent(-5)(new byte[-5]→OverflowExceptionon net462) throws straight past theSocketException/IOExceptionfilter. A malformed header kills the connection with an argument-validation exception rather than the gracefulnullevery other malformed-frame path produces.Unparseable — quieter and arguably worse.
_ = int.TryParse(...)discards the result, andout contentSizeis set to0on failure. So a header that failed to parse was read as a valid frame with an empty body, and only failed later as a JSON error pointing at the payload instead of at the header that was actually wrong.Both now return
-1, soReadAsyncreturnsnulllike every other unusable frame.Content-Length: 0is untouched — it's well-formed framing and still reaches the formatter.2. The read path transcoded bytes → chars → bytes
JsonDocument.Parse(ReadOnlyMemory<char>)immediately transcodes back to UTF-8 into its own pooled array:So every frame did
GetCharCount+Rent<char>+GetChars, and STJ undid all three — while the raw UTF-8 bytes were already sitting inbodyBuffer. The formatter now takesReadOnlyMemory<byte>on .NET and the body goes straight through. net462/Jsonite is unchanged (it only accepts a string).This also retires the note that was sitting on
IMessageFormatterasking for exactly this:and removes
Deserialize<T>(ReadOnlyMemory<char> utf8Json)— acharbuffer with autf8name, which is the byte/char ambiguity #10297 exists to fix, one layer down.Lifetime note, since this is the subtle part.
JsonDocument.Parse(ReadOnlyMemory<byte>)does not copy; it reads out of the buffer until the document is disposed. The pooled buffer therefore has to outlive the document, and it does —Deserializedisposes the document afterBindhas fully materialized the graph, and only then does thefinallyreturn the buffer.This requirement is not new: the char overload rents its own byte array and returns it on
Dispose, so anything retaining aJsonElementpastDeserializewould already have been broken. I checked all the deserializers — including the recursiveIDictionary<string, object?>andobject[]ones, which are the only plausible offenders — and every one materializes into owned objects (string,Dictionary,object[], primitives). Nothing boxes aJsonElement. Documented on both sides so it stays that way.3. Comments that overstated their guarantee
The header-buffer comment justified unbounded growth by pointing at
Content-Length's trust boundary. The two aren't equivalent: a body is legitimately unbounded (stack traces, captured stdout), which is what makes refusing to cap it correct; a header line has no legitimate large case, and a peer that never sends\ndoubles the buffer forever. Still uncapped, but the comment now says which argument actually applies and that headers are the cheaper place for a cap if one is ever wanted.Also documented that
ReadHeaderLineAsyncbreaks only on LF, whereas theStreamReaderit replaced also treated a lone CR as a terminator. Protocol-irrelevant and arguably stricter-is-better, but the comment read as if it enumerated the cases.Tests
ReadAsync_MalformedContentLength_ReturnsNull— negative,int.MinValue, larger-than-Int32, non-numeric, empty.ConnectedHandlers.CreateAsyncno longer leaks the listener and a half-open socket if connect/accept throws. On a loopback-heavy suite that surfaces as port exhaustion in an unrelated test rather than as the failure that caused it.ReadAsync_LeadingByteOrderMark_IsSkippedis now ASCII-only. It carriedNonAsciiMethod, so pre-Fix Content-Length byte/char mismatch in the server-mode TCP transport #10297 it failed for the byte/char reason rather than isolating BOM handling; it can now only fail if preamble handling itself breaks.FormatterUtilitiesTestsgets aDeserialize<T>(string)helper hiding the per-TFM split, which removes eight#if NETCOREAPPblocks and fourSA1009/SA1111pragma pairs that only existed to work around a#ifin the middle of a call expression.Verification
Guard confirmed load-bearing by neutralizing just the
if (!parsed || contentSize < 0)condition and re-running — all five rows fail, two withArgumentOutOfRangeExceptionand three withJsonReaderException, matching the probe above. Restoring it returns them to green.Microsoft.Testing.Platform.UnitTests(net8.0)Microsoft.Testing.Platform.UnitTests(net9.0)Microsoft.Testing.Platform.UnitTests(net462)Microsoft.Testing.Platform.ServerClient.UnitTests(net8.0)Microsoft.Testing.Platform.ServerClient.UnitTests(net462)Platform suites are up 5 from the 1447/1423 baseline #10297 established — the five new data rows.
ServerClient.UnitTestsis unchanged at 24, which matters because that project compilesTcpMessageHandler.cs,IMessageFormatter.csandFormatterUtilities.csas source and exercises the net462/Jsonite side of every#iftouched here.