Skip to content

Add TDS token data length bounds checks#4340

Draft
paulmedynski wants to merge 8 commits into
mainfrom
dev/paul/tds-data-len-bounds
Draft

Add TDS token data length bounds checks#4340
paulmedynski wants to merge 8 commits into
mainfrom
dev/paul/tds-data-len-bounds

Conversation

@paulmedynski
Copy link
Copy Markdown
Contributor

@paulmedynski paulmedynski commented Jun 4, 2026

Summary

Adds bounds checks on data lengths read from the TDS wire to prevent unbounded byte[] allocation from a malicious server.

All checks throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, ...) when a spoofed length exceeds protocol-reasonable maximums.

Changes

File Change Description Tests
TdsParser.cs L4180 TryProcessSessionState — total length check Rejects tokenLen > MaxTokenDataLength (1 MB) SessionState_OversizedTotalLength
TdsParser.cs L4233 TryProcessSessionState — inner option length check Rejects individual state option with stateLen > MaxTokenDataLength SessionState_OversizedInnerOptionLength
TdsParser.cs L4454 TryProcessFedAuthInfo — token length check Rejects tokenLen > MaxTokenDataLength FedAuthInfo_OversizedTokenLength
TdsParser.cs L3351 TryProcessEnvChange — PromoteTransaction newLength check Rejects newLength > MaxPromoteTransactionLength (64 KB) EnvChange_PromoteTransaction_OversizedLength (login), BatchResponse_PromoteTransaction_OversizedLength (post-login)
TdsParser.cs L3853 TryProcessFeatureExtAck — data length check Rejects dataLen > MaxTokenDataLength FeatureExtAck_OversizedDataLength, FeatureExtAck_MaxAllowedDataLength_DoesNotThrow
TdsParser.cs L7144 TryReadSqlDateTime — data length check Rejects length > MaxDateTimeLength (10) BatchResponse_DateTime_OversizedLength
TdsParser.cs L4937-4948 TryProcessReturnValue — non-PLP length check Restructured: IsPlp types skip bounds check; non-PLP rejects valLen > int.MaxValue BatchResponse_ReturnValue_OversizedLength, BatchResponse_ReturnValue_PlpUnknownLength
TdsParser.cs L7405 TryReadSqlValueInternal — binary/vector length check Rejects length > MAXSIZE (8000) for binary types in sql_variant path BatchResponse_SqlVariantBinary_OversizedLength
SqlConnectionInternal.cs L1349 OnFeatureExtAck — SRECOVERY inner state length Validates inner state option length doesn't exceed remaining buffer SRecovery_MalformedInnerStateLength
TdsEnums.cs L90-96 Constants MaxTokenDataLength (1 MB), MaxPromoteTransactionLength (64 KB), MaxDateTimeLength (10) (used by all above)

Impact analysis

Group 1: New upper-bound constraints

These add hard limits where previously the parser would attempt to allocate whatever the server claimed. A conforming SQL Server will never exceed any of these limits, but a misbehaving proxy, network appliance, or third-party TDS implementation could.

Location Limit Rationale
FeatureExtAck dataLen 1 MB No legitimate feature ack payload approaches this
SessionState total length 1 MB Session state tokens are typically single-digit KB
SessionState inner option 1 MB Individual state options are even smaller
FedAuthInfo tokenLen 1 MB Contains only STS URL + SPN — a few hundred bytes
PromoteTransaction newLength 64 KB DTC propagation tokens are typically ~200–500 bytes
DateTime length 10 bytes DateTimeOffset (the largest) is exactly 10; this is the protocol ceiling

Risk to existing apps: Effectively zero against real SQL Server. Could break connections through a non-conforming TDS proxy that inflates token lengths (e.g., for padding or encapsulation).

Group 2: Debug.Assert → runtime exception (bug fix)

These replaced Debug.Assert (which is stripped in Release builds, meaning the invalid state was silently accepted) with a throwing check:

Location Previous behavior (Release) New behavior
ReturnValue non-PLP valLen > int.MaxValue Silently truncated to int.MaxValue Throws ParsingErrorLength
Binary/Vector in sql_variant length > MAXSIZE No check at all (assert stripped) Throws ParsingErrorLength

Risk to existing apps: Only if the app was previously "surviving" corrupt data by silently reading garbage. In practice these indicate a corrupted stream that would likely cause downstream failures anyway.

Group 3: Buffer overrun prevention (defense-in-depth)

Location Issue prevented
SqlConnectionInternal.OnFeatureExtAck SRECOVERY: len < 0 || len > data.Length - i Out-of-bounds Buffer.BlockCopy from a malformed feature ack payload

Risk to existing apps: None — this path only fires if the server sends internally inconsistent SRECOVERY data (claimed inner length exceeds the outer buffer already validated by the FeatureExtAck check).

Group 4: Behavioral improvement (non-breaking)

Location Change
TryReadSqlDateTime Replaced ((uint)length <= 16) ? stackalloc : new byte[length] with unconditional stackalloc byte[10]

Since the bounds check guarantees length ≤ 10, the conditional heap allocation path is dead code eliminated. Minor perf win. No user-visible behavior change.

Impact summary

Category Count Risk to legitimate apps
New constraints 6 sites ~Zero (limits far exceed protocol maximums)
Assert → exception 2 sites Near-zero (previously silent corruption)
Buffer overrun fix 1 site None
Perf improvement 1 site None

The only scenario where a real application could be affected is if it connects through a non-conforming TDS intermediary (proxy/load balancer) that rewrites token lengths to values exceeding these bounds. Standard SQL Server, Azure SQL, and conforming TDS 7.x/8.0 implementations will never produce values exceeding any of these limits.

Test infrastructure

  • Added OnSQLBatchCompleted hook to GenericTdsServer for post-login TDS token injection
  • Added OnAuthenticationResponseCompleted hook usage for login-phase token injection
  • 12 unit tests covering all bounds checks (login-phase and post-login via batch injection)

Checklist

  • Tests added or updated
  • No breaking changes introduced
  • PLP regression verified (test 9 confirms valid PLP sentinel is not rejected)
  • 100% patch coverage on new bounds checks

Prevent unbounded memory allocation from malicious TDS servers by adding
runtime bounds checks on token data length fields before heap allocation.

Changes:
- Add MaxTokenDataLength (1 MB) and MaxPromoteTransactionLength (64 KB)
  constants to TdsEnums.cs
- Add bounds checks in TryProcessFeatureExtAck, TryProcessSessionState,
  TryProcessFedAuthInfo, ENV_PROMOTETRANSACTION, and TryReadSqlValueInternal
- Add bounds check in SqlConnectionInternal SRECOVERY secondary parse
- Promote Debug.Assert to runtime checks in TryProcessReturnValue and
  TryReadSqlValueInternal (binary types), remove now-redundant asserts
- Replace conditional heap allocation in TryReadSqlDateTime with fixed
  stackalloc guarded by a length check

Tests:
- FeatureExtAckBoundsTests: oversized FeatureExtAck data length, positive
  boundary test
- TdsTokenBoundsTests: oversized SessionState total/inner length, malformed
  SRECOVERY inner state, oversized FedAuthInfo token length
Inject a malicious PromoteTransaction environment change token (type 15) into
the login response with a fraudulently large int32 newLength field. The bounds
check in TryProcessEnvChange rejects it before allocation.

No simulated server extension needed — env change tokens are part of the
standard login response and can be injected via OnAuthenticationResponseCompleted.
The bounds check on valLen must not apply to PLP (Partial Length
Prefixed) types because TryReadPlpLength can return chunk lengths
that exceed int.MaxValue for legitimate streaming data (e.g.,
NVARCHAR(MAX) output parameters). PLP types always use int.MaxValue
as a sentinel for 'read all data', so the unchecked cast was safe.

Restructure the if/else to check IsPlp first, applying the
bounds check only to non-PLP return values where an oversized
length indicates a corrupted TDS stream.
- Add OnSQLBatchCompleted hook to GenericTdsServer for post-login
  TDS token injection testing
- Add BatchResponse_DateTime test verifying TryReadSqlDateTime
  rejects oversized length (11 bytes for TimeN, max is 10)
- Fix test isolation: all login-phase tests now clear their
  OnAuthenticationResponseCompleted hook in finally blocks
- Add DebugAssertSuppressor helper for clean test teardown after
  intentional stream corruption
- Test 8: BatchResponse_ReturnValue_OversizedLength injects an IMAGE
  RETURNVALUE token with data length -1 (huge when cast to ulong),
  verifying TryProcessReturnValue rejects non-PLP values > int.MaxValue
- Test 9: BatchResponse_ReturnValue_PlpUnknownLength sends a valid
  PLP VARBINARY(MAX) RETURNVALUE with the unknown-length sentinel
  (0xFFFFFFFFFFFFFFFE) + immediate terminator, confirming the PLP
  path is not rejected by the bounds check
Copilot AI review requested due to automatic review settings June 4, 2026 16:31
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Jun 4, 2026
@paulmedynski paulmedynski moved this from To triage to In progress in SqlClient Board Jun 4, 2026
@paulmedynski paulmedynski added this to the 7.1.0-preview2 milestone Jun 4, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the TDS parser against pre-auth (and post-auth) denial-of-service vectors by adding upper bounds checks on token/data lengths read from the wire, preventing unbounded allocations when a server spoofs lengths.

Changes:

  • Added parser-side bounds checks for multiple TDS tokens/fields (SessionState, FedAuthInfo, FeatureExtAck, EnvChange PromoteTransaction, DateTime payloads, ReturnValue non‑PLP, Vector).
  • Introduced new protocol max constants in TdsEnums and expanded simulated-server infrastructure to inject malicious tokens in both login and batch responses.
  • Added new simulated-server unit tests covering the new rejection paths and a PLP regression guard.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/TdsTokenBoundsTests.cs New simulated-server tests covering several oversized-length scenarios (login + post-login injection).
src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/FeatureExtAckBoundsTests.cs New tests for FeatureExtAck oversized length handling and boundary acceptance.
src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/GenericTdsServer.cs Adds a post-login SQL batch hook to allow response token injection in tests.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs Implements the core bounds checks in the TDS parser and fixes PLP/non‑PLP handling in ReturnValue.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs Adds new max-length constants used by the parser checks.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Connection/SqlConnectionInternal.cs Adds bounds validation for SRECOVERY inner state option parsing in FeatureExtAck handling.

Comment thread src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs Outdated
@paulmedynski paulmedynski changed the title Add TDS token data length bounds checks (pre-auth DoS fix) Add TDS token data length bounds checks Jun 4, 2026
- ReturnValue: report actual valLen via unchecked((int)valLen) instead of
  int.MaxValue for more actionable error diagnostics
- TryReadSqlDateTime: use TdsEnums.MaxDateTimeLength constant instead of
  local const for consistency with other bounds checks
- SqlConnectionInternal: use ParsingErrorLength for session-recovery check
  to include the offending length in the error
- TdsEnums: broaden MaxTokenDataLength comment scope (not login-only),
  add MaxDateTimeLength constant
- FeatureExtAckBoundsTests: rewrite boundary test to inject token at
  exactly MaxTokenDataLength and assert bounds check does not fire
- TdsTokenBoundsTests: fix DateTime test comment (heap, not stack)
@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.74%. Comparing base (bfbdd30) to head (9f4bcf0).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4340      +/-   ##
==========================================
- Coverage   66.69%   64.74%   -1.95%     
==========================================
  Files         284      279       -5     
  Lines       43238    66097   +22859     
==========================================
+ Hits        28836    42796   +13960     
- Misses      14402    23301    +8899     
Flag Coverage Δ
CI-SqlClient ?
PR-SqlClient-Project 64.74% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI review requested due to automatic review settings June 5, 2026 12:16
The binary bounds check in TryReadSqlValueInternal is only reachable through
the sql_variant deserialization path (TryReadSqlVariant). For non-variant
binary columns, TryReadSqlValue handles them directly without calling
TryReadSqlValueInternal. This test injects a sql_variant column containing a
BigVarBinary value whose inner data length (8001) exceeds MAXSIZE (8000),
verifying the bounds check fires and prevents unbounded heap allocation.

Addresses the 2 uncovered lines flagged by Codecov in PR #4340.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs Outdated
- TryProcessFeatureExtAck: store (int)dataLen in local variable after bounds
  check to avoid repeated uint-to-int casts
- FeatureExtAckBoundsTests: wrap OversizedDataLength test body in try/finally
  to clear OnAuthenticationResponseCompleted hook, preventing state leakage
  into other simulated-server tests sharing the same fixture
Copy link
Copy Markdown
Contributor Author

@paulmedynski paulmedynski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review: Test coverage annotations

Each bounds check added to driver code has been annotated with the specific test(s) that exercise it. Summary:

Driver change Test(s)
TdsEnums.cs — MaxTokenDataLength, MaxPromoteTransactionLength, MaxDateTimeLength All 12 tests validate these thresholds
TdsParser.cs — PromoteTransaction bounds check (L3351) EnvChange_PromoteTransaction_OversizedLength_ThrowsParsingError, BatchResponse_PromoteTransaction_OversizedLength_ThrowsParsingError
TdsParser.cs — FeatureExtAck bounds check (L3853) FeatureExtAck_OversizedDataLength_ThrowsParsingError, FeatureExtAck_MaxAllowedDataLength_DoesNotThrow
TdsParser.cs — SessionState total length (L4178) SessionState_OversizedTotalLength_ThrowsParsingError
TdsParser.cs — SessionState inner option (L4234) SessionState_OversizedInnerOptionLength_ThrowsParsingError
TdsParser.cs — FedAuthInfo token length (L4455) FedAuthInfo_OversizedTokenLength_ThrowsParsingError
TdsParser.cs — ReturnValue non-PLP length (L4936) BatchResponse_ReturnValue_OversizedLength_ThrowsParsingError, BatchResponse_ReturnValue_PlpUnknownLength_Succeeds
TdsParser.cs — DateTime length (L7143) BatchResponse_DateTime_OversizedLength_ThrowsParsingError
TdsParser.cs — Binary/Vector sql_variant (L7405) BatchResponse_SqlVariantBinary_OversizedLength_ThrowsParsingError
SqlConnectionInternal.cs — SRECOVERY inner state (L1349) SRecovery_MalformedInnerStateLength_ThrowsParsingError

All 12 tests pass in ~19s.

Comment on lines +4234 to +4237
if (stateLen < 0 || stateLen > TdsEnums.MaxTokenDataLength)
{
throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, stateLen);
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: TdsTokenBoundsTests.SessionState_OversizedInnerOptionLength_ThrowsParsingError — injects a SessionState token with a valid outer length but an inner state option whose 0xFF + DWORD encoded length exceeds MaxTokenDataLength.

Comment on lines 4178 to +4181
{
throw SQL.ParsingErrorLength(ParsingErrorState.SessionStateLengthTooShort, length);
}
if (length > TdsEnums.MaxTokenDataLength)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: TdsTokenBoundsTests.SessionState_OversizedTotalLength_ThrowsParsingError — injects a SessionState token (0xE4) whose total length field exceeds MaxTokenDataLength.

Comment on lines +3351 to +3354
if (env._newLength < 0 || env._newLength > TdsEnums.MaxPromoteTransactionLength)
{
throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, env._newLength);
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: TdsTokenBoundsTests.EnvChange_PromoteTransaction_OversizedLength_ThrowsParsingError and TdsTokenBoundsTests.BatchResponse_PromoteTransaction_OversizedLength_ThrowsParsingError — the first injects during login, the second during command execution via OnSQLBatchCompleted.

Comment on lines +3853 to 3860
if (dataLen > (uint)TdsEnums.MaxTokenDataLength)
{
throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, (int)dataLen);
}
int dataLength = (int)dataLen;
byte[] data = new byte[dataLength];
if (dataLength > 0)
{
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: FeatureExtAckBoundsTests.FeatureExtAck_OversizedDataLength_ThrowsParsingError (injects dataLen = MaxTokenDataLength + 1, asserts parsing error state 18) and FeatureExtAckBoundsTests.FeatureExtAck_MaxAllowedDataLength_DoesNotThrow (boundary test confirming no off-by-one).

Comment on lines +4455 to +4458
if (tokenLen > TdsEnums.MaxTokenDataLength)
{
throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, tokenLen);
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: TdsTokenBoundsTests.FedAuthInfo_OversizedTokenLength_ThrowsParsingError — injects a FedAuthInfo token (0xEE) with tokenLen exceeding MaxTokenDataLength. The token type is dispatched unconditionally by TryRun.

Comment on lines +4936 to +4948
int intlen;

if (rec.metaType.IsPlp)
{
intlen = int.MaxValue; // If plp data, read it all
}
else if (valLen > (ulong)int.MaxValue)
{
throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, unchecked((int)valLen));
}
else
{
intlen = (int)valLen;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: TdsTokenBoundsTests.BatchResponse_ReturnValue_OversizedLength_ThrowsParsingError (injects IMAGE return value with data length -1, which as ulong exceeds int.MaxValue) and TdsTokenBoundsTests.BatchResponse_ReturnValue_PlpUnknownLength_Succeeds (regression guard: PLP unknown-length sentinel 0xFFFFFFFFFFFFFFFE must NOT trigger the check).

Comment on lines +7143 to 7153
// DateTimeOffset is the largest datetime type at 10 bytes (5 time + 3 date + 2 offset).
// Reject anything larger to prevent heap allocation from spoofed metadata.
if (length < 0 || length > TdsEnums.MaxDateTimeLength)
{
throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, length);
}

Span<byte> datetimeBuffer = stackalloc byte[TdsEnums.MaxDateTimeLength];

TdsOperationStatus result = stateObj.TryReadByteArray(datetimeBuffer, length);
if (result != TdsOperationStatus.Done)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: TdsTokenBoundsTests.BatchResponse_DateTime_OversizedLength_ThrowsParsingError — injects a TIME column whose data length byte is 11 (exceeding MaxDateTimeLength = 10). Also validates the elimination of the conditional heap allocation: since all valid datetime types fit in ≤ 10 bytes, this is now an unconditional stackalloc.

Comment on lines +1349 to +1353
if (len < 0 || len > data.Length - i)
{
throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, len);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: TdsTokenBoundsTests.SRecovery_MalformedInnerStateLength_ThrowsParsingError — injects a FeatureExtAck with SessionRecovery feature (ID=1) whose inner state data contains an option claiming length 999 while only 1 byte follows. The bounds check len < 0 || len > data.Length - i catches this overflow.

Comment on lines 7405 to +7408
// Note: Better not come here with plp data!!
Debug.Assert(length <= TdsEnums.MAXSIZE);
if (length < 0 || length > TdsEnums.MAXSIZE)
{
throw SQL.ParsingErrorLength(ParsingErrorState.CorruptedTdsStream, length);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: TdsTokenBoundsTests.BatchResponse_SqlVariantBinary_OversizedLength_ThrowsParsingError — injects a sql_variant ROW containing a BigVarBinary inner type whose computed lenData (8001) exceeds MAXSIZE (8000). This is the only path to reach this check since normal binary columns go through TryReadSqlValue directly.

Comment on lines +87 to 98
// Maximum allowed data length for token payloads (feature ext ack,
// session state, fedauth info). Prevents a malicious server from causing
// unbounded memory allocation via spoofed token length fields.
internal const int MaxTokenDataLength = 1 << 20; // 1 MB

// Maximum allowed data length for a DTC promote transaction propagation token.
internal const int MaxPromoteTransactionLength = 1 << 16; // 64 KB

// Maximum valid wire size for datetime types (DateTimeOffset = 5 time + 3 date + 2 offset).
internal const int MaxDateTimeLength = 10;

// Severity 0 - 10 indicates informational (non-error) messages
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: These constants are validated by all 12 unit tests across FeatureExtAckBoundsTests (2 tests) and TdsTokenBoundsTests (10 tests). Each test verifies that crossing the respective threshold triggers ParsingErrorState.CorruptedTdsStream. The boundary test (MaxAllowedDataLength_DoesNotThrow) also confirms no off-by-one in the MaxTokenDataLength constant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

2 participants