Skip to content

fix: close host-scoping deny-list bypasses via alternate address encodings (#635) - #649

Merged
MCKRUZ merged 4 commits into
mainfrom
fix/635-host-scoping-bypasses
Sep 11, 2026
Merged

fix: close host-scoping deny-list bypasses via alternate address encodings (#635)#649
MCKRUZ merged 4 commits into
mainfrom
fix/635-host-scoping-bypasses

Conversation

@MCKRUZ

@MCKRUZ MCKRUZ commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #635. CapabilityEnforcer's network-host allow/deny check compared a requested host and
a configured deny/allow entry as raw strings — so a blocked address (e.g. a cloud metadata
endpoint) could be reached by spelling it a different but equivalent way. This PR canonicalizes
both sides of the comparison through the same host resolution the real HTTP client uses, closing
six distinct alternate-encoding bypass classes:

  • Decimal/hex/short-form IPv4 (2130706433, 0x7f.0.0.1, 127.1)
  • Unicode label separators / zero-width characters (evil。com, a trailing zero-width space)
  • Bracketed / zone-qualified IPv6 ([::1], fe80::1%eth0)
  • IPv4-mapped IPv6 (::ffff:127.0.0.1) — found during code review, verified live against the
    compiled enforcer
  • The older, RFC 4291-deprecated IPv4-compatible IPv6 form (::127.0.0.1, no ffff) — found in a
    second review round, fixed with an explicit loopback/unspecified-address exclusion (naively
    taking the last 4 bytes of ::1 gives 0.0.0.1, not 127.0.0.1 — verified and guarded against)
  • Bare values carrying @/#/?/\ silently reducing to just their leading host segment where
    they were previously refused outright as malformed

Also adds an advisory warning when a configured deny/allow entry can never match anything (a
typo'd pattern previously became a silent, permanent no-op).

Review history

Two full /code-review rounds plus a nudge-nudge-verified coordinator pass, run-gates.sh's
grader/correctness/security/owasp gates clean 5/5 runs (opus-tier automated review independently
found the IPv4-mapped-IPv6 bypass and the ambiguous-character gap before I'd finished my own
second pass). Every fix mutation-tested (temporarily reverted, confirmed the new test fails,
restored). 62/62 CapabilityEnforcementTests pass.

Local test gate failed 5 consecutive times on the same unrelated, pre-existing flaky test class
(ProcessSandboxExecutorTests/SandboxAttestationBindingTests — sandbox process execution,
unrelated to this diff, confirmed clean in isolation every time) — pushed via the sanctioned
RAILS_SKIP_REVIEW_GATE=1 bypass, deferring to CI's independent (non-contended) run.

Follow-ups filed (out of scope for this PR)

Test plan

  • dotnet build src/AgenticHarness.slnx
  • CapabilityEnforcementTests: 62/62 pass
  • Every fix mutation-tested (revert → confirm test fails → restore → confirm passes)
  • run-gates.sh: build/owasp/grader/correctness/security/docs-drift clean 5/5; test gate's
    failure isolated and confirmed as pre-existing, unrelated flakiness
  • Verified empirically against the real .NET BCL and the compiled enforcer at every step
    (throwaway console apps + live temp tests), not asserted from documentation alone

🤖 Generated with Claude Code

https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu

MCKRUZ and others added 3 commits September 11, 2026 14:52
CapabilityEnforcer's NormalizeHostForMatch compared requested hosts and
configured deny/allow patterns as raw strings, so a deny entry could be
evaded by spelling the same address differently: decimal/hex/short-form
IPv4 (2130706433, 0x7f.0.0.1, 127.1), a Unicode label separator or
zero-width character (evil。com), a bracketed or zone-qualified IPv6
literal ([::1], fe80::1%eth0), or IPv4-mapped IPv6 (::ffff:127.0.0.1 for
127.0.0.1 — found by code review, verified live against the compiled
enforcer). Now canonicalizes through Uri.IdnHost (what the real HTTP
client actually connects with) plus explicit zone-id truncation and
IPv4-mapped-IPv6 collapse, mirroring the normalization
CompositeHookExecutor.IsReservedAddress already does for the same
address class. Also adds an advisory warning when a configured
deny/allow entry can never match anything (a typo'd pattern silently
became a permanent no-op before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
…rse, correct overstated doc claim (#635)

run-gates.sh's correctness and security gates found the synthetic-scheme
parsing added for #635 silently reduced a bare value carrying '@', '#',
'?', or '\' to just its leading host segment, where it was previously
refused outright as malformed. Harmless today (matches what the real
HTTP client would connect to), but any template consumer's future tool
that resolves the raw value some other way (DNS, socket, subprocess)
would be checked against a different string than it contacts — excluded
alongside '/' rather than left to widen silently.

Also corrects an inaccurate doc-comment claim the grader gate flagged:
"no tool issues a network request through anything other than
Uri/HttpClient" is false (several tools run subprocesses via
ISandboxExecutor); the actually-verified fact is that zero production
tools declare a Host-kind resource parameter, so EnforceHostScoping
never runs with a non-empty requestedHosts today regardless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
…hrow claim (#635)

Round-2 code review found a second, distinct alternate-IPv6-encoding
bypass beyond the IPv4-mapped form already fixed: the older,
RFC-4291-deprecated "IPv4-compatible" form (::a.b.c.d, no ffff prefix)
still parses successfully and IPAddress.IsIPv4MappedToIPv6 doesn't
recognize it, so a plain DeniedHosts=["127.0.0.1"] entry didn't refuse
"::127.0.0.1"/"::7f00:1". Fixed with its own guarded helper rather than
a blind "first 12 bytes zero" check — naively taking the last 4 bytes
of "::1" (loopback) gives "0.0.0.1", not 127.0.0.1, so loopback and the
unspecified address are excluded via the same IPAddress.IsLoopback/
IPv6Any checks the BCL itself uses.

Also corrects this file's own doc comment: verified live that
Uri.IdnHost DOES throw UriFormatException for a mixed valid-character-
plus-invalid-Unicode label, contradicting the earlier "does not throw"
claim (that test batch only tried pure-invalid labels, which fail
earlier and never reach IdnHost). The existing fail-closed sentinel
fallback already handled this correctly — this only fixes what the
comment claimed about it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
@github-actions

Copy link
Copy Markdown

Correctness review — PR #649

Bottom line: CORRECT — no blocking correctness defect found.

What I checked

Anchor set is two files: CapabilityEnforcer.HostScoping.cs (the Uri/IdnHost host
canonicalization rewrite) and the CapabilityEnforcementTests.cs additions.

Rather than reason from the diff's own prose, I rebuilt the old and new
NormalizeHostForMatch against the pinned BCL in a throwaway console app and ran both over a
60-shape corpus (URI-shaped, bare, IPv4 alternate encodings, bracketed/zone/mapped/compatible IPv6,
Unicode separators, zero-width, punycode, wildcard patterns, userinfo/fragment/query/backslash,
empty/whitespace, malformed ports), scoring each output through SecureInputValidatorHelper.ValidateHost.

Result: every divergence between old and new is in the tightening direction (a value that
previously normalized to a non-canonical string now reduces to the connect-time canonical host).
No corpus entry moved from "refused" to "permitted". Specifically confirmed:

  • CapabilityEnforcer.HostScoping.cs:226 — the "\0" sentinel does fail ValidateHost
    (SecureInputValidatorHelper.cs:98, explicit Contains('\0')), so the IdnHost-throws path
    (a�.com) is genuinely fail-closed on the requested side, and produces *.\0 on a wildcard
    pattern, which WarnIfPatternIsInert still flags (it strips the prefix before validating,
    :331).
  • CapabilityEnforcer.HostScoping.cs:195 — the '/' '@' '#' '?' '\' exclusion keeps those shapes
    on the StripPort path, where Uri.CheckHostName still rejects them. A full absolute URI with
    userinfo (http://user@evil.com/x) is unaffected because branch 1 runs first.
  • CapabilityEnforcer.HostScoping.cs:202 — percent-escaped host spellings (ev%69l.com) fail the
    synthetic parse outright and stay refused; they do not reach the '%' zone truncation at
    :258 and get silently truncated to a prefix. I checked this specifically because that was the
    most plausible way the new '%' handling could have opened a bypass.
  • CapabilityEnforcer.HostScoping.cs:102 / :174 — the StartsWith("*.") → ordinal change is a
    fix (the old overload was culture-sensitive); all three call sites now agree.
  • Test helper CapabilityEnforcementTests.cs:80-86 reads Arguments[2] = the TState
    (FormattedLogValues), whose ToString() is the formatted message — correct, and the
    Build overload pair at :59-63 resolves unambiguously.

dotnet test --filter CapabilityEnforcementTests62 passed, 0 failed.

Advisory (not blocking)

Anchor Concern
src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.HostScoping.cs:316 TryGetDeprecatedIPv4CompatibleForm collapses every ::a.b.c.d except loopback/unspecified — confirmed ::20.0.0.2 and ::8.8.8.88.8.8.8. On the deny side that is strictly safe over-blocking. On the allow side it widens an entry to a genuinely different address: AllowedHosts=["8.8.8.8"] now permits a requested ::8.8.8.8. RFC-4291-correct as a format classification, and the exploit value is near zero (the deprecated form isn't routable to a party the attacker controls, and the file itself notes no production tool declares a Host parameter), so ADVISE rather than BLOCK — but the allow-side asymmetry isn't mentioned in the remarks, which argue the collapse purely from the deny side.
src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.HostScoping.cs:260 Zone truncation makes fe80::1%eth0 and fe80::1%eth9 normalize identically. Deliberate and documented, but same asymmetry: an operator who scoped an allow entry to one interface now permits all of them.
src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.HostScoping.cs:51 WarnIfPatternIsInert runs on every EnforceHostScoping call, not once at config load — a single typo'd entry emits a LogWarning per tool invocation for the life of the process. Diagnostics-only, but it's an unbounded-volume warning on a hot path; a config-time or once-per-profile check would say the same thing once.
src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.HostScoping.cs:247 catch (Exception) is broader than the UriFormatException the remarks justify it with. Fail-closed on the requested side; on a configured deny entry it silently disables that entry (mitigated — WarnIfPatternIsInert logs it, since "\0" fails ValidateHost).

Single most important thing for a human to look at: the allow-side direction of the
IPv4-compatible collapse at :316 — every justification in the diff's comments is written from the
deny side, and the allow side is where a normalization that equates two distinct addresses widens
rather than narrows.

@github-actions

Copy link
Copy Markdown

🔒 Security review — PR #649

Verdict: BLOCK — 1 HIGH finding. Everything else below is advisory.

Scope reviewed: CapabilityEnforcer.HostScoping.cs + CapabilityEnforcementTests.cs (the two files in the gate's scope file). No widening was needed.

First, the part that is right, because it matters for reading the finding below. I replayed the pre-#635 normalization against all 21 host/deny pairs the new tests exercise: 17 of 17 bypass-closure cases were ALLOWED before this PR and are refused after it, and the 4 guard cases (notevil.com, xn--vil-9ma.com, 2001:db8::1, ::1 vs 0.0.0.1) confirm the fix does not over-fire. The tests genuinely discriminate — they are not passing for the wrong reason. All 62 tests in the class pass locally. This PR is a real, substantial improvement and introduces no regression versus main.

The problem is that it stops one step short, in the exact class it claims to close.


🔴 HIGH — CanonicalizeParsedHost never re-canonicalizes an IPv4 literal after IDNA mapping: one fullwidth digit reopens the whole alternate-IPv4-encoding bypass, including SSRF to the cloud metadata endpoint

src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.HostScoping.cs:269-275

if (IPAddress.TryParse(host, out var ip))
{
    if (ip.IsIPv4MappedToIPv6)  host = ip.MapToIPv4().ToString();
    else if (TryGetDeprecatedIPv4CompatibleForm(ip, out var ipv4)) host = ipv4.ToString();
    // <-- plain IPv4: the successfully-parsed `ip` is discarded, `host` keeps the raw string
}

The PR's decimal/hex/short-form IPv4 closure (2130706433, 0x7f.0.0.1, 127.1) does not come from this block at all — it comes from Uri itself canonicalizing the host to dotted-quad when the host is pure ASCII. If the value contains any non-ASCII character, Uri classifies the host as a Unicode reg-name and never applies IPv4 canonicalization; IdnHost then IDNA-maps the fullwidth digits back to ASCII digits, producing a non-canonical IPv4 string that this block parses successfully and then throws away.

So the two evasion classes the remarks enumerate separately (alternate IPv4 radix; Unicode digit/separator mapping) are each closed, and their composition is wide open.

Verified against the compiled Application.AI.Common.dll from this branch (reflection onto NormalizeHostForMatch), not a replica:

requested host normalizes to Dns.GetHostAddresses actually resolves to
2852039166 (U+FF12 + 852039166) 2852039166 169.254.169.254
2130706433 2130706433 127.0.0.1
0x7f.0.0.1 0x7f.0.0.1 127.0.0.1
0251.0376.0251.0376 0251.0376.0251.0376 169.254.169.254
127.1 127.1 127.0.0.1

(Contrast the pure-ASCII spellings, which this PR does correctly collapse: 2852039166169.254.169.254, 2130706433127.0.0.1.)

Attack path. Given DeniedHosts: ["169.254.169.254"] (the deny entry from #635's own issue text) and a tool declaring a ResourceParameterKind.Host parameter:

  1. Agent/model-controlled argument supplies host 2852039166.
  2. NormalizeHostForMatch"2852039166".
  3. SecureInputValidatorHelper.ValidateHost("2852039166") passesUri.CheckHostName returns IPv4, so the malformed-input gate does not catch it.
  4. "2852039166" != "169.254.169.254" → no deny match → EnforceHostScoping returns nullcall permitted.
  5. The tool connects. Dns.GetHostAddresses("2852039166")169.254.169.254 (IPAddress.TryParse accepts 1-part decimal and octal/hex octets, confirmed on the pinned BCL). IMDS reachable → managed-identity token theft.

The same shape defeats an AllowedHosts allowlist in reverse, and defeats DeniedHosts: ["127.0.0.1"] for loopback.

Why HIGH rather than advisory. I weighed the mitigation the file itself documents — zero shipped tools declare a Host resource parameter, so requestedHosts is empty today (I confirmed: only FileSystemTool declares ResourceParametersByOperation, and it declares Path). That is real, and it is the same mitigation that applies to every bug this PR fixes. It cannot make this finding advisory while the PR treats the identical exposure as worth fixing. What makes it blocking is the second half: the remarks assert this class is closed, and DeniedHost_AlternateIPv4Encoding_StillMatchesDottedQuadDenyEntry asserts it in a test. Merging leaves a documented-and-tested closure that a single codepoint walks through — the "control that reads as enforcing but isn't" failure mode recorded six times in CLAUDE.md.

Fix — one line, in the block above:

if (IPAddress.TryParse(host, out var ip))
{
    if (ip.IsIPv4MappedToIPv6) host = ip.MapToIPv4().ToString();
    else if (TryGetDeprecatedIPv4CompatibleForm(ip, out var ipv4)) host = ipv4.ToString();
    else host = ip.ToString(); // canonicalize ANY parseable literal, not just the two IPv6 forms
}

This is also the correct canonicalization by construction rather than by enumeration: Dns.GetHostAddresses — the value actually consumed — resolves a numeric host through IPAddress.TryParse, so canonicalizing through the same parser makes the checked value equal the consumed value for every radix, octet count, and digit script at once, instead of closing encodings one at a time.

I checked the other direction, per CLAUDE.md's standing rule that a narrowing fix needs its own false-negative test: with else host = ip.ToString() applied, all five bypass variants close and every other case is byte-identical127.0.0.1, ::1, [::1], fd00::1, 2001:db8::1, fe80::1%eth0, ::ffff:127.0.0.1, ::127.0.0.1, evil.com, notevil.com, xn--vil-9ma.com, evil。com, *.evil.com, sub.evil.com, localhost, evil.com:443 all normalize exactly as they do today. It cannot over-fire on a DNS name, because IPAddress.TryParse fails on one.

Please add a [Theory] alongside DeniedHost_AlternateIPv4Encoding_StillMatchesDottedQuadDenyEntry covering at least 2852039166 against DeniedHosts: ["169.254.169.254"] — the existing test's pure-ASCII inputs pass without this fix, so it cannot detect the regression.


🟡 Advisory (non-blocking)

LOW — the IPv4-compatible collapse widens an AllowedHosts allowlist, which the remarks only justify in the deny direction. CapabilityEnforcer.HostScoping.cs:299TryGetDeprecatedIPv4CompatibleForm collapses ::a.b.c.d on both sides. Deny-side that is the intended tightening; allow-side it is a widening: AllowedHosts: ["10.0.0.5"] now admits requested ::10.0.0.5 and ::a00:5 (verified, both → 10.0.0.5), which at connect time is IPv6 ::a00:5, a different address. Practically inert (::/96 is deprecated and unroutable, and .NET resolves it as IPv6; the attacker also cannot choose the target freely — it is pinned to whatever IPv4 the operator allowlisted). Worth one sentence in the remarks acknowledging the allow-direction asymmetry, since the current text reasons only about deny entries.

LOW — catch (Exception) is broader than the one exception it was written for and logs nothing. :247. The UnnormalizableHostSentinel design is sound and correctly fail-closed on the requested-host side — that part I verified (a�.com"\0"ValidateHost rejects → refused). Two smaller points: it also swallows OutOfMemoryException / OperationCanceledException, so narrow it to UriFormatException/ArgumentException; and the remarks' claim that the throw is observable through existing logging is only half true — the emitted message says "host denied" or "can never match", never that IdnHost threw, so the one input class most likely to be adversarial is the one the operator cannot distinguish in logs.

LOW — WarnIfPatternIsInert fires per tool call, not once at config load. :51-52, :327. A single typo'd deny entry emits one warning per invocation of that tool — unbounded log growth on a hot tool, and the noisiest signal is the one an operator learns to filter. This repo already has the right mechanism for a startup-time config assertion (the *ConfigValidator convention plus SecurityControlHasACallerTests.EveryConfigValidator_IsBoundIntoTheOptionsPipeline, which would keep it from becoming another unbound validator). Behaviourally the current placement is harmless — advisory only.

LOW — normalization runs before any length bound. NormalizeHostForMatch performs a LINQ Count, two string interpolations, and up to two Uri parses on an unbounded caller-controlled string; MaxHostLength is only enforced afterward in ValidateHost. Pre-existing, marginally amplified here. A cheap length guard at the top of NormalizeHostForMatch would cap it.

Informational — residual gap now named, so it is a known one. The remarks correctly say the list is "NOT verified exhaustive". Concretely: normalization is Uri-shaped, while a subprocess tool (terraform/curl/wget via ISandboxExecutor) resolves through inet_aton semantics. Fixing the HIGH above aligns the two for numeric hosts specifically, because IPAddress.TryParse and inet_aton accept the same radix forms — a good reason to prefer the parser-based fix over further enumeration.


Bottom line

The normalization rewrite is well-built, the empirical method behind it is the right one, and the test suite provably discriminates — I verified all 17 bypass closures against pre-#635 behaviour rather than taking the comments' word for it. One HIGH blocks: a single fullwidth digit reopens the headline alternate-IPv4-encoding bypass, reaching 169.254.169.254, because a successfully-parsed IPAddress is discarded for plain IPv4. The fix is one line plus one [InlineData], and I have verified it closes all five variants with zero change to any other case. Re-request review after that and this passes.

…literals (#635)

CI's security-review gate (running independently on the pushed PR)
found a second-order bug in the fix itself: a fullwidth Unicode digit
("2", U+FF12) defeats Uri's own up-front IPv4-literal recognition, so
"2852039166" classifies as HostNameType.Dns rather than IPv4. IdnHost
then IDNA/NFKC-folds the fullwidth digit to plain ASCII as a side
effect of DNS-label normalization, producing "2852039166" — a valid
legacy decimal encoding of 169.254.169.254 (the cloud metadata
address) — but Uri never re-evaluates HostNameType against its own
normalized output, so the value was never collapsed to the dotted-quad
form the very first commit in this PR already handles for a plain
(all-ASCII) decimal literal.

Verified live: re-running the same single-pass normalization on its
own output resolves this correctly, since the second pass sees pure
ASCII digits up front. NormalizeBareHost now re-normalizes to a fixed
point (bounded at 4 iterations, not unconditional) rather than a
single pass, so this closes the class of bug regardless of how many
confusable/normalization layers an adversarial value stacks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
@github-actions

Copy link
Copy Markdown

Grader verdict — PR #649

Intent: Close six alternate-encoding bypass classes in CapabilityEnforcer's network-host allow/deny matching (decimal/hex/short IPv4, Unicode label separators/zero-width chars, bracketed/zone-qualified IPv6, IPv4-mapped IPv6, deprecated IPv4-compatible IPv6, and userinfo/fragment/query/backslash-bearing bare values) by canonicalizing both sides of the comparison through Uri.IdnHost, plus an advisory warning for permanently-inert deny/allow entries.

Check-by-check

Claim Verdict Evidence
Decimal/hex/short-form IPv4 now matches dotted-quad deny entry ✅ met CapabilityEnforcer.HostScoping.cs:161-310 (NormalizeBareHostOnce/CanonicalizeParsedHost); CapabilityEnforcementTests.cs:758-1102 (DeniedHost_AlternateIPv4Encoding_StillMatchesDottedQuadDenyEntry)
Unicode label separator / zero-width char normalizes to ASCII via IdnHost ✅ met HostScoping.cs:264-282 (uri.IdnHost); test DeniedHost_UnicodeLabelSeparatorOrZeroWidthChar_StillMatchesAsciiDenyEntry
Bracketed / zone-qualified IPv6 collapses to bare/zone-free form ✅ met HostScoping.cs:284-292; tests DeniedHost_BracketedIPv6Literal_..., DeniedHost_IPv6ZoneIdentifier_...
IPv4-mapped IPv6 (::ffff:127.0.0.1) collapses to IPv4 ✅ met HostScoping.cs:301-304; test DeniedHost_Ipv4MappedIpv6Literal_StillMatchesPlainIPv4DenyEntry
Deprecated IPv4-compatible IPv6 (::127.0.0.1, no ffff) collapses, excluding loopback/unspecified ✅ met HostScoping.cs:312-350 (TryGetDeprecatedIPv4CompatibleForm); tests DeniedHost_DeprecatedIpv4CompatibleIpv6Literal_..., AllowedHost_Ipv6Loopback_IsNotMisidentifiedAsAnUnrelatedIPv4Address (guards the naive-last-4-bytes bug the PR describes avoiding)
Userinfo/#/?/\ bare values stay refused as malformed rather than silently reduced ✅ met HostScoping.cs:217-228 (exclusion list in NormalizeBareHostOnce); test DeniedHostOnly_BareValueWithUserinfoFragmentQueryOrBackslash_StillRefused
Fixed-point re-normalization catches post-IDNA-fold IPv4 literals (fullwidth digit → decimal IPv4) ✅ met HostScoping.cs:198-210 (NormalizeBareHost, bounded 4-iteration loop); test DeniedHost_FullwidthDigitEncodedDecimalIPv4_StillMatchesDenyEntry
IdnHost throw is fail-closed, not fallback to raw Host ✅ met HostScoping.cs:258-282 (UnnormalizableHostSentinel); test DeniedHostOnly_RequestedHostTriggersIdnHostException_StillRefused
Advisory warning for a deny/allow entry that can never match ✅ met HostScoping.cs:45-53, 352-371 (WarnIfPatternIsInert); tests DeniedHost_MalformedConfiguredEntry_LogsInertConfigurationWarning, ..._WellFormedConfiguredEntry_DoesNotLog...
No regression / over-broad matching (unrelated hosts, punycode look-alikes) ✅ met tests AllowedHost_UnrelatedPrefixCollisionHost_StillRefused, AllowedHost_PunycodeLookalike_DoesNotCollideWithUnicodeDenyEntry, AllowedHost_UnrelatedIpv6_DoesNotCollideWithIpv4MappedNormalization
Build clean, 62/62 (now 63/63) CapabilityEnforcementTests pass ✅ met independently re-ran: dotnet build src/AgenticHarness.slnx — 0 errors; dotnet test .../Application.AI.Common.Tests.csproj --filter CapabilityEnforcementTests — 63/63 passed

Holes

Standards check

  • File size: CapabilityEnforcer.HostScoping.cs is 397 lines — under the 400-line bar, but only by 3 lines; the file is now almost entirely doc comments (the PR's own /review-changes-style remarks are unusually dense). No violation, but close enough to flag for the Checker.
  • Function length: longest new method (CanonicalizeParsedHost) is ~46 lines — under the 50-line bar.
  • No new public types were added (all new members are private/private static), so the "XML docs on every public type" rule doesn't apply here — existing private methods are documented anyway, exceeding the bar.
  • No Result<T>, keyed-DI, or skill-training-handler concerns apply to this diff — none of those patterns are touched.
  • Fail-closed posture is consistent throughout (unnormalizable → sentinel → refused; malformed configured pattern → warned, not silently trusted) — matches this repo's SSRF/host-scoping precedent (CompositeHookExecutor.IsReservedAddress, cited and mirrored rather than reinvented).

Bottom line

LOOKS GOOD. All claims in the PR description are backed by a changed line and a passing, independently-re-run test (63/63). The most important thing for the human Checker to look at: the doc-comment-disclosed assumption that no production tool resolves a host outside Uri/HttpClient today (CapabilityEnforcer.HostScoping.cs:146-155) — that's a live invariant, not a permanent guarantee, and there's no automated check that would catch it silently going stale the day a subprocess-based tool grows a Host resource parameter.

@github-actions

Copy link
Copy Markdown

🔐 Security review — PR #649 · SECURITY_VERDICT: PASS

Scope: CapabilityEnforcer.HostScoping.cs + CapabilityEnforcementTests.cs (the two files the gate flagged). Reviewed the full diff against origin/main, plus SecureInputValidatorHelper.ValidateHost and StripPort/ValidateHosts/HostPatternMatches as the surrounding trust boundary.

I did not take the doc comments' empirical claims on trust — I re-ran the normalization against the pinned BCL (net10.0, standalone harness replicating NormalizeHostForMatch byte-for-byte, ~55 adversarial inputs). Results below.

Verified good (no finding)

  • Deny-list evasion classes close as claimed. Every alternate encoding of 127.0.0.1 / 169.254.169.254 I could construct collapses to the dotted quad: decimal (2130706433), octal (0177.0.0.1, 0251.0376.0251.0376), hex (0x7f.0.0.1, 0xA9FEA9FE), short form (127.1), IPv4-mapped (::ffff:127.0.0.1), IPv4-compatible (::127.0.0.1, ::7f00:1), bracketed, zone-qualified, trailing-dot, :port.
  • The fixed-point loop is adequately bounded. Max passes observed across all inputs was 2 (fullwidth digit → IDNA fold → decimal-IPv4 collapse), including stacked confusables I added beyond the two the comment names (circled digits ⓪①②.0.0.110.0.0.1; fullwidth-zero hex 0x7f.0.0.1127.0.0.1; all-fullwidth decimal 2852039166169.254.169.254). The 4-iteration cap has ~2× headroom; I could not construct a non-converging or oscillating value.
  • Requested-host side is fail-closed. Every shape that escapes canonicalization lands on a string ValidateHost rejects, so ValidateHosts returns a violation rather than silently admitting it: %31%32%37.0.0.1, %65vil.com, evil%2ecom, evil<TAB>com, evil.com/, evil.com@x, *.evil.com, and the UnnormalizableHostSentinel ("\0", rejected by ValidateHost's explicit NUL check — confirmed at SecureInputValidatorHelper.cs:96).
  • UnnormalizableHostSentinel on the allow-list side is fail-closed too — an all-sentinel AllowedHosts refuses everything rather than degenerating to allow-all.
  • No over-broadening. notevil.com, xn--vil-9ma.com, 2001:db8::1, ::1::1 (correctly not collapsed to 0.0.0.1), evil_host.com (underscore hosts still normalize cleanly — no availability regression), ::ffff:0:127.0.0.1 all behave correctly.
  • WarnIfPatternIsInert logs structured config values only — no secret/PII exposure, no log-injection sink.

MEDIUM — bracket-prefixed value defeats the @ # ? \ / exclusion the PR just added

CapabilityEnforcer.HostScoping.cs:227 routes these shapes to StripPort(value).TrimEnd('.'), and StripPort's bracket branch (:382-385) returns only the bracket contents, discarding everything after ]. So the invariant DeniedHostOnly_BareValueWithUserinfoFragmentQueryOrBackslash_StillRefused asserts — "must stay refused as malformed, not silently reduced to a bare host" — holds only for values that don't start with [:

[allowed.com]@evil.com   -> allowed.com   (ValidateHost = true)
[allowed.com]/x          -> allowed.com
[127.0.0.1]\evil.com     -> 127.0.0.1

With AllowedHosts = ["allowed.com"], a requested [allowed.com]@evil.com passes the allow-list. Rated MEDIUM, not HIGH, for two reasons: (a) this is pre-existing — the StripPort bracket fallback predates #635 and produced the same result before this diff; (b) no realistic consumer reaches evil.com from that string (new Uri("http://[allowed.com]@evil.com/") throws, and curl/raw-socket read [ as an IPv6 literal and fail), so today it's a hardening gap rather than an exploit. It's worth noting because this PR is the one that states the invariant and adds the test for it — the test only uses non-bracketed values.

Suggested fix: hoist the IndexOfAny(['/', '@', '#', '?', '\\']) check above the bracket branch, or return UnnormalizableHostSentinel for those shapes instead of StripPort — the sentinel already gives you the fail-closed + logged behaviour you want, and matches the comment's own stated intent.

LOW — TryGetDeprecatedIPv4CompatibleForm collapses all of ::/96, not just IPv4-compatible spellings

:331-350 collapses any IPv6 address with 96 zero bits (minus loopback/unspecified), so ::20.0.0.2 and ::203.0.113.5203.0.113.5. On the deny side this only over-blocks (safe). On the allow side it's a theoretical widening: AllowedHosts = ["203.0.113.5"] would admit a requested ::203.0.113.5, which is a different destination. Not exploitable in practice (::/96 is deprecated and effectively unroutable), so no action required — but if you want it tight, gate the collapse on the value having been written in the dotted ::a.b.c.d form rather than inferring it from the byte prefix.

LOW — inert-pattern warning re-fires on every enforcement call

WarnIfPatternIsInert (:51-52) runs inside EnforceHostScoping, i.e. once per tool invocation per bad entry. A single typo'd deny entry on a hot tool floods the log. Consider emitting it once per resolved profile (or caching by toolName + pattern) — the signal is a startup/config concern, not a per-call one.

Bottom line

No HIGH findings. This diff is a net security improvement: it closes several real deny-list evasion classes, both sides of every comparison use identical normalization, and every path that fails to canonicalize fails closed through ValidateHost. The test additions are unusually good — the guard cases (::1 vs. 0.0.0.1, notevil.com, xn--vil-9ma.com, unrelated IPv6) test the over-broadening direction, which is the direction that normally goes untested. Not blocking. The MEDIUM is pre-existing and worth a follow-up, not a merge stopper.

@github-actions

Copy link
Copy Markdown

Correctness review — PR #649

Method: reproduced NormalizeHostForMatch / NormalizeBareHost / CanonicalizeParsedHost / TryGetDeprecatedIPv4CompatibleForm verbatim in a throwaway console app against the same pinned BCL and ran ~60 inputs through them, plus an old-vs-new parity sweep against origin/main's algorithm.

Blocking defects

None.

Checks that came back clean (all empirically, not by reading):

  • No regression on pre-existing shapes. Old vs. new normalization agree on every bare host, host:port, absolute URI, wildcard pattern, bracketed IPv6, and the embedded-scheme case example.com/https://evil.com that AllowedHost_UrlWithEmbeddedSchemeLaterInString_DoesNotMatchTheEmbeddedHost guards. The only divergence is case (EVIL.COM.evil.com vs EVIL.COM), which is inert because every comparison in HostPatternMatches is OrdinalIgnoreCase.
  • https://*.evil.com does not break. Uri.TryCreate rejects * in the authority, so this falls to the same StripPort path as on main — the wildcard-prefix refactor at CapabilityEnforcer.HostScoping.cs:161 does not strand scheme-prefixed wildcard entries.
  • Fail-closed paths hold. The UnnormalizableHostSentinel (:258) does fail ValidateHost (Contains('\0')), so a�.com → refusal, and a sentinel-valued pattern is both unmatchable and warned. Empty / whitespace / *. / \0 patterns all normalize to something WarnIfPatternIsInert flags without throwing (pattern[2..] on a bare "*." yields "", not an exception).
  • The '@' '#' '?' '\' '/' exclusion at :227 is reachable and correct — all four shapes normalize unchanged and are refused by ValidateHost, matching main.
  • The '%' zone truncation at :290 cannot fire on a DNS host. Uri.TryCreate("http://good.com%2eevil.com/") fails outright, so the truncation is confined to IPv6 zone ids as intended — no allowed.com%… label-truncation bypass.
  • Fixed-point loop terminates and converges. Max observed depth is 3 passes (28520391662852039166169.254.169.254); no oscillation found.
  • TryGetDeprecatedIPv4CompatibleForm does not misfire on ::1/::IPAddress.IsLoopback returns false for ::7f00:1, true for ::1, so the collapse is correct in both directions.

Advisory (not blocking)

  1. src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.HostScoping.cs:305 — the deprecated-IPv4-compatible collapse fires on the whole ::/96 block, not just addresses that are meaningfully IPv4. Verified: ::a00:110.0.0.1, ::5db8:d82293.184.216.34. On a deny list that is over-blocking (safe); on an allow list it widens the entry — AllowedHosts=["10.0.0.1"] now admits a requested ::a00:1, which the real stack routes as IPv6 to an unroutable ::/96 address, not to 10.0.0.1. That is a checked-value ≠ consumed-value divergence in the permissive direction, i.e. the same hazard the '@'/'#'/'?'/'\' exclusion at :227 was added to avoid. Not blocking: the destination is unreachable, so nothing is actually contacted, and per this file's own remarks no shipped tool declares a ResourceParameterKind.Host parameter today. Worth either restricting the collapse to a deny-side-only normalization or documenting the allow-side widening explicitly.

  2. src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.HostScoping.cs:201 — the 4-iteration bound is silent on non-convergence: it returns the 4th intermediate, so a hypothetical value needing a 5th pass would be matched in a non-canonical form with no signal. The bound is adequate for everything I could construct (max 3), but returning UnnormalizableHostSentinel on exhaustion instead of current would make the fallback fail-closed for free, consistent with the sentinel decision made two functions down.

  3. src/Content/Application/Application.AI.Common/Services/Sandbox/CapabilityEnforcer.HostScoping.cs:51WarnIfPatternIsInert runs per enforced call (after the requestedHosts.Count == 0 early return), which means (a) one malformed config entry emits a warning on every invocation of that tool, and (b) a tool whose hosts are never requested never warns at all, so the operator signal this exists to provide is missing exactly where host scoping is configured but unexercised. A config-validation-time check would be both quieter and more complete.

  4. src/Content/Tests/Application.AI.Common.Tests/Behaviors/CapabilityEnforcementTests.cs:758 — several deny-side tests assert only result.IsSuccess.Should().BeFalse(). Refusal is also the outcome when the normalized host merely fails ValidateHost, so these do not discriminate "the deny entry matched" from "the input was rejected as malformed". Asserting the refusal message contains host denied would tie each test to the path it names.

Bottom line

CORRECT — no blocking defect found. The single thing worth a human's eyes is advisory 1: TryGetDeprecatedIPv4CompatibleForm collapses every ::/96 address to a dotted quad, which is a one-way widening of an allow list (::a00:110.0.0.1) rather than the deny-list tightening the surrounding comments describe.

@MCKRUZ
MCKRUZ merged commit e41970c into main Sep 11, 2026
7 checks passed
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.

CapabilityEnforcer host-scoping: 3 normalization bypasses let a deny-list entry be evaded

1 participant