Skip to content

Fix SSRF via nullable-bool allowlist fail-open in SessionsPythonPlugin#14153

Open
tmacam wants to merge 3 commits into
microsoft:mainfrom
tmacam:validate-allowed-domains
Open

Fix SSRF via nullable-bool allowlist fail-open in SessionsPythonPlugin#14153
tmacam wants to merge 3 commits into
microsoft:mainfrom
tmacam:validate-allowed-domains

Conversation

@tmacam

@tmacam tmacam commented Jul 14, 2026

Copy link
Copy Markdown

Summary

Replaces a confusing, easy-to-misread nullable-bool operator-precedence expression in SessionsPythonPlugin.SendAsync() that governed the domain allowlist, and hardens/clarifies the surrounding validation.

Problem

The original validation expression:

if (!this._settings.AllowedDomains?.Contains(uri.Host) ?? false)

parses as (!nullable_bool) ?? false due to C# operator precedence:

AllowedDomains ?.Contains() ! result ?? false Outcome
null null null false No throw (endpoint host allowed)
["a.io"], host = "a.io" true false false Allowed
["a.io"], host = "b.io" false true true Blocked

The expression is correct for the explicit-allowlist rows but is opaque and relies on bool? null-propagation through ! and ??, which is a well-known footgun and hard to audit for a security-sensitive check.

Fix

Replace the expression with explicit, readable logic and make the host comparison case-insensitive (DNS semantics; matches HttpPlugin/WebFileDownloadPlugin):

var effectiveAllowedDomains = this._settings.AllowedDomains
    ?? new[] { this._poolManagementEndpoint.Host };

if (!effectiveAllowedDomains.Contains(uri.Host, StringComparer.OrdinalIgnoreCase))
{
    throw new InvalidOperationException("Sending requests to the provided location is not allowed.");
}

Threat model / scope (updated after review)

An earlier version of this description claimed the null-AllowedDomains default blocks IMDS. That is not accurate for this plugin and has been corrected:

  • In SendAsync, uri = new Uri(_poolManagementEndpoint, relativePath) and every caller passes a hardcoded relative path ("executions", "files", …), so uri.Host is invariantly _poolManagementEndpoint.Host.
  • Therefore the null default ({ endpoint.Host }) is a readability / backward-compatibility replacement for the old (!x) ?? false expression — it does not add a restriction and does not, by itself, block IMDS.
  • The only path that actually restricts which host the plugin talks to is an explicitly configured AllowedDomains, which is now correct and case-insensitive.

Why this is safe

  • Backward compatible: users who never set AllowedDomains see no behavior change — requests go to the endpoint they configured at construction time.
  • Readable / auditable: no nullable-bool subtlety — the intent is obvious.
  • Explicit allowlist works correctly: arbitrary hosts are blocked when AllowedDomains is configured, regardless of casing.

Tests

Test Verifies
ItShouldRespectAllowedDomainsAsync (+ new FAKE-TEST-HOST.io case) Explicit allowlist matching, including case-insensitive host comparison
ItShouldAllowConfiguredEndpointHostWhenAllowedDomainsIsNullAsync Null AllowedDomains permits the configured endpoint host, and the request is actually sent there
ItShouldBlockEndpointHostNotInAllowedDomainsAsync An explicit allowlist blocks IMDS even when it is the configured endpoint

Known follow-up (out of scope here)

Redirect-based SSRF is not closed by this change: HttpClient follows 3xx redirects by default and the allowlist is only checked against the initial URI. Because the client comes from the injected IHttpClientFactory, this plugin cannot force AllowAutoRedirect = false on an already-constructed client without changing the construction contract. This is pre-existing and best handled as a focused follow-up (named/typed client registration with a non-redirecting handler).

References

  • MSRC Case 125929 / VULN-200115
  • ICM 31000000656989

…thonPlugin

The domain allowlist validation in SendAsync used a confusing nullable-bool
expression that silently allowed all hosts when AllowedDomains was null:

    if (!this._settings.AllowedDomains?.Contains(uri.Host) ?? false)

Due to C# operator precedence, `!` binds before `??`, so the null case
(unconfigured AllowedDomains) evaluated to `false` — skipping the check
entirely and permitting requests to any URL, including Azure IMDS
(169.254.169.254) and internal VNet services.

Replace with explicit logic that defaults to the configured endpoint's own
host when AllowedDomains is null. This preserves backward compatibility
(legitimate requests still reach their configured session pool) while
closing the fail-open path.

Add three regression tests covering the null-AllowedDomains scenarios.

Ref: MSRC 125929 / VULN-200115
@tmacam
tmacam requested a review from a team as a code owner July 14, 2026 19:30
Copilot AI review requested due to automatic review settings July 14, 2026 19:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 updates the SessionsPythonPlugin domain allowlist validation to remove a nullable-bool precedence pitfall, aiming to prevent a fail-open scenario when AllowedDomains is left unconfigured, and adds unit tests to cover the intended behavior/regression.

Changes:

  • Replaces the nullable/?? allowlist check in SendAsync with explicit “effective allowed domains” logic.
  • Adds tests for AllowedDomains = null behavior and a regression test tied to the referenced MSRC case.
  • Normalizes file headers (BOM removal) in the touched files.

Reviewed changes

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

File Description
dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs Reworks host allowlist validation logic in SendAsync to avoid nullable-bool precedence fail-open.
dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs Adds new tests covering the null-AllowedDomains scenario and a regression case.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs Outdated
Comment thread dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs Outdated
Comment thread dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs Outdated
Comment thread dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs Outdated
The .editorconfig requires charset=utf-8-bom for .cs files. The prior
edit stripped the byte-order mark, causing the check-format CI job to
fail with a CHARSET error.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Restore UTF-8 BOM on SessionsPythonPlugin.cs and SessionsPythonPluginTests.cs
  so the check-format (dotnet format) CHARSET gate passes; .editorconfig requires
  charset=utf-8-bom for .cs files.
- Use StringComparer.OrdinalIgnoreCase for the host allowlist comparison to match
  DNS semantics and the convention used by other plugins (HttpPlugin, etc.).
- Rewrite the misleading null-AllowedDomains block test to genuinely exercise the
  explicit-allowlist path (ItShouldBlockEndpointHostNotInAllowedDomainsAsync),
  removing the dead variable and rambling commentary.
- Consolidate the duplicated/ineffective regression test into
  ItShouldAllowConfiguredEndpointHostWhenAllowedDomainsIsNullAsync, which now also
  asserts the request is sent to the configured endpoint host.
- Add a case-insensitive allowlist test case and correct the code comment so it no
  longer overstates the null-default as IMDS protection.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@tmacam
tmacam deployed to integration July 14, 2026 20:03 — with GitHub Actions Active
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.

2 participants