Skip to content

fix(mcptransport): aggregate OriginValidation bypass variants into one finding (LAB-5584) - #288

Open
praetorian-farida wants to merge 1 commit into
mainfrom
faridashafik/lab-5584-originvalidation-aggregate
Open

fix(mcptransport): aggregate OriginValidation bypass variants into one finding (LAB-5584)#288
praetorian-farida wants to merge 1 commit into
mainfrom
faridashafik/lab-5584-originvalidation-aggregate

Conversation

@praetorian-farida

Copy link
Copy Markdown
Collaborator

Fixes LAB-5584

Problem

mcptransport.OriginValidation emitted one scored attempt per bypass variant. A server that validates no Origin fails every variant, so a single flaw surfaced as ten identical 1.0 rows.

Per the ticket's corpus measurement: 260 of 308 findings across thirteen HTTP MCP targets — 84% — were this one check. On one lab it was 100% of the output. On the benign server-everything reference server it was 10 of 12 rows, so a server with no vulnerabilities still produced twelve red rows.

The ten variants (two external origins, null, two extension origins, two localhost-lookalikes, a case variant, two unexpected Hosts) are ten ways of asking one question — does this endpoint enforce the Origin/Host allowlist the spec requires — and a server that validates nothing answers all ten identically.

The check itself is sound and stays: the finding is real, the loopback/LAN vs public severity tiering is well reasoned, and the CORS half is already separated. The defect was purely that ten proofs of one property were presented as ten vulnerabilities.

Guard was checked first, per the ticket

emitPerProbeRisks in Guard (backend/pkg/tasks/capabilities/augustus/augustus.go) already buckets findings by probe name and emits one risk per probe. The ten attempts have always collapsed to one llm-mcptransport-originvalidation risk, so the customer-facing risk count was never wrong.

What was wrong Guard-side is the proof attachment on that single risk: ten near-identical ScanFindings blobs. This change makes it one legible finding. No Guard change is needed — evidenceFromMetadata copies attempt metadata verbatim, so the new keys flow through untouched, and nothing in the Guard backend or frontend is coupled to the class values (grepped).

Change

One scored attempt for the endpoint (class origin-validation-sweep) carrying every crafted value as evidence.

Per-variant detail is preserved deliberately — which variants pass is what a remediator acts on. A case-variant-only bypass means the allowlist exists but compares case-sensitively; that's a different fix from an endpoint that accepts any origin.

[origin-validation-sweep] 7 of 9 crafted Origin/Host values accepted

Validator verdict: validation is PARTIAL — the accepted classes below are the checks that are missing

ACCEPTED — served a request it should have refused
  [external-origin] Origin=https://cdn-bbd41350.example.net    -> HTTP 200, application/json
  [null-origin] Origin=null                                    -> HTTP 200, application/json
  ...
REJECTED — refused as a hardened server should
  [external-origin] Origin=https://bbd41350.example.com        -> HTTP 403, text/plain
  ...

Credentialed CORS reflection: PRESENT. ... it can also READ the responses ...

Representative accepted exchange (...): POST ... -> HTTP 200 ...
  • Baseline and preflight attempts unchanged, as the ticket asked.
  • Host-class severity tiering unchanged — the sweep carries the same target-class stamp, so the detector needed no logic change.
  • Credentialed CORS reflection escalates the aggregated impact from "a page can drive the tool surface blind" to "a page can also read the responses", conditionally, plus a credentialed_read metadata key.
  • New metadata: ..._variants (structured per-variant list), ..._accepted_classes, ..._variants_sent, ..._variants_accepted, ..._credentialed_read.

One judgement call worth reviewing

When every variant dies in transit, the sweep now reports as errored rather than safe. Collapsing to one row is only sound if that row is trustworthy, and a dead endpoint reporting SAFE would be worse than the noise this removes. Test locks it in.

Verification

Built the binary before and after the change and ran both against the live DVMCP corpus (challenges 1-10, legacy SSE transport):

BEFORE AFTER
findings 90 10
attempts 110 30

Every challenge went 9 findings → 1 (nine not ten because these bind 127.0.0.1:900X, an all-numeric host where the case variant is correctly skipped as a would-be FP).

No information lost — machine-checked that the set of variant classes in the nine BEFORE findings is identical to the set in the one AFTER finding's evidence, on all ten challenges.

Negative control: the DVMCP streamable-HTTP servers (mcp 1.28.1, hardened because FastMCP auto-enables DNS-rebinding protection when constructed with the default loopback host) score clean both before and after, and the aggregated evidence states it positively — "every crafted value was refused — Origin/Host validation is enforced." So the collapse doesn't manufacture a finding where there isn't one, and a hardened server reads as one green row instead of eleven.

Note the corpus here is the SSE half of DVMCP only, not the ticket's full thirteen targets, so this doesn't reproduce its exact 230-finding projection.

go build ./..., full go test ./..., gofmt, and golangci-lint (0 issues) all pass.

🤖 Generated with Claude Code

…ding (LAB-5584)

mcptransport.OriginValidation emitted one scored attempt per bypass
variant. A server that validates no Origin fails every variant, so a
single flaw surfaced as ten identical 1.0 rows — 84% of everything the
MCP probes produced across our target corpus, and 10 of 12 rows on a
benign reference server.

The ten variants are ten ways of asking one question: does this endpoint
enforce the Origin/Host allowlist the MCP spec requires. Fold them into a
single scored attempt for the endpoint, class origin-validation-sweep,
carrying every crafted value as evidence.

The per-variant detail is preserved, because which variants pass is what
a remediator acts on: a case-variant-only bypass means the allowlist
exists but compares case-sensitively, a different fix from an endpoint
that accepts any origin. Evidence renders ACCEPTED / REJECTED / NOT
TESTED buckets plus a validator verdict, and the metadata carries the
structured variant list, accepted classes, and counts.

Credentialed CORS reflection found by the preflight escalates the
aggregated finding's stated impact from "a page can drive the tool
surface blind" to "a page can also read the responses".

Baseline and preflight attempts are unchanged. Host-class severity
tiering is unchanged — the sweep carries the same target-class stamp, so
the detector needs no change.

When every variant dies in transit the sweep reports as errored rather
than safe: collapsing to one row is only sound if that row is
trustworthy, and a dead endpoint reporting SAFE is worse than the noise
this removes.

Measured against DVMCP challenges 1-10 over the legacy SSE transport,
before/after the change: findings 90 -> 10, attempts 110 -> 30, with the
set of accepted variant classes identical in both (no information lost).
The hardened DVMCP streamable-HTTP servers stay clean in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codex Review

Critical Issues

  • internal/probes/mcptransport/originvalidation.go:517 — If some variants fail in transit while at least one is rejected, the aggregate is marked complete with accepted=false, producing a SAFE score despite most checks being untested. Partial transport failures should make a non-vulnerable result inconclusive/error.
  • internal/probes/mcptransport/originvalidation.go:418 — A failed CORS preflight is reduced to credentialedRead=false; internal/probes/mcptransport/originvalidation.go:554 then reports reflection as “absent.” Preserve preflight failure state and report “not tested” rather than asserting absence.

Security

No security concerns flagged.

Suggestions

No suggestions.


Reviewed by Codex (gpt-5.6-sol)

// Set on the aggregated sweep attempt because it escalates that finding's
// impact: without it a rebound page drives the tool surface blind; with it
// the page can also read the responses.
MetadataKeyOriginValidationCredentialedRead = "mcptool.originvalidation_credentialed_read"
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The origin-validation probe now aggregates crafted Origin and Host results into one endpoint-level sweep finding. The finding records accepted, rejected, and transport-failed variants with evidence and representative transcripts. Baseline and preflight attempts remain separate. The probe handles request-construction and transport failures as variant results, preserves SSE timeout handling, and escalates credentialed CORS reflection. Metadata keys, detector scoring, and tests now use the aggregated sweep model.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch faridashafik/lab-5584-originvalidation-aggregate

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/probes/mcptransport/originvalidation_test.go (1)

161-193: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fail loudly if the metadata type changes.

Both helpers discard the type-assertion result. If the probe ever stores a different shape, the helpers return empty values and the callers report misleading errors instead of the real contract break.

♻️ Proposed change
-	classes, _ := raw.([]string)
+	classes, ok := raw.([]string)
+	if !ok {
+		t.Fatalf("accepted-classes metadata is %T, want []string", raw)
+	}

Apply the same pattern to sweepVariants.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/probes/mcptransport/originvalidation_test.go` around lines 161 -
193, Update acceptedVariantClasses and sweepVariants to validate the metadata
type assertions instead of discarding their boolean results; fail immediately
with t.Fatal when the stored value is not the expected []string or
[]map[string]any shape, then continue building and returning the parsed values
for valid metadata.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/probes/mcptransport/originvalidation.go`:
- Around line 569-580: Update validatorVerdict so the enforced verdict is
returned only when accepted == 0, rejected > 0, and errored == 0; when errored
variants exist, report the result as incomplete or inconclusive while preserving
the existing verdicts for fully rejected, entirely unresolved, and fully
accepted cases.

---

Nitpick comments:
In `@internal/probes/mcptransport/originvalidation_test.go`:
- Around line 161-193: Update acceptedVariantClasses and sweepVariants to
validate the metadata type assertions instead of discarding their boolean
results; fail immediately with t.Fatal when the stored value is not the expected
[]string or []map[string]any shape, then continue building and returning the
parsed values for valid metadata.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a5ca41d4-b384-449b-819b-3d6cc95d2e67

📥 Commits

Reviewing files that changed from the base of the PR and between e10dd22 and 1e7d608.

📒 Files selected for processing (5)
  • internal/detectors/mcptransport/originvalidation.go
  • internal/detectors/mcptransport/originvalidation_test.go
  • internal/probes/mcptransport/originvalidation.go
  • internal/probes/mcptransport/originvalidation_test.go
  • pkg/attempt/metadata_keys.go

Comment on lines +569 to +580
func validatorVerdict(accepted, rejected, errored int) string {
switch {
case accepted == 0 && rejected > 0:
return "every crafted value was refused — Origin/Host validation is enforced"
case accepted == 0:
return "no variant was accepted, but none completed cleanly either — inconclusive"
case rejected == 0 && errored == 0:
return "NO Origin/Host validation is enforced — every crafted value was accepted"
default:
return "validation is PARTIAL — the accepted classes below are the checks that are missing"
}
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Qualify the "enforced" verdict when variants failed in transit.

validatorVerdict returns "validation is enforced" whenever accepted == 0 && rejected > 0, even if some variants never reached the server. The verdict line is the summary a remediator reads, so it should state the incomplete coverage.

🔧 Proposed fix
 	switch {
+	case accepted == 0 && rejected > 0 && errored > 0:
+		return fmt.Sprintf("every crafted value that landed was refused, but %d never completed — validation appears enforced, coverage is partial", errored)
 	case accepted == 0 && rejected > 0:
 		return "every crafted value was refused — Origin/Host validation is enforced"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func validatorVerdict(accepted, rejected, errored int) string {
switch {
case accepted == 0 && rejected > 0:
return "every crafted value was refused — Origin/Host validation is enforced"
case accepted == 0:
return "no variant was accepted, but none completed cleanly either — inconclusive"
case rejected == 0 && errored == 0:
return "NO Origin/Host validation is enforced — every crafted value was accepted"
default:
return "validation is PARTIAL — the accepted classes below are the checks that are missing"
}
}
func validatorVerdict(accepted, rejected, errored int) string {
switch {
case accepted == 0 && rejected > 0 && errored > 0:
return fmt.Sprintf("every crafted value that landed was refused, but %d never completed — validation appears enforced, coverage is partial", errored)
case accepted == 0 && rejected > 0:
return "every crafted value was refused — Origin/Host validation is enforced"
case accepted == 0:
return "no variant was accepted, but none completed cleanly either — inconclusive"
case rejected == 0 && errored == 0:
return "NO Origin/Host validation is enforced — every crafted value was accepted"
default:
return "validation is PARTIAL — the accepted classes below are the checks that are missing"
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/probes/mcptransport/originvalidation.go` around lines 569 - 580,
Update validatorVerdict so the enforced verdict is returned only when accepted
== 0, rejected > 0, and errored == 0; when errored variants exist, report the
result as incomplete or inconclusive while preserving the existing verdicts for
fully rejected, entirely unresolved, and fully accepted cases.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e7d6088a0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +571 to +572
case accepted == 0 && rejected > 0:
return "every crafted value was refused — Origin/Host validation is enforced"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat partially errored sweeps as inconclusive

When one crafted variant times out or is dropped but at least one other variant returns a clean rejection, this branch reports that every value was refused; aggregateSweep then completes the attempt with accepted=false, so the detector scores the whole sweep SAFE even though part of the validator was never tested. Treat errored > 0 && accepted == 0 as inconclusive/error instead of enforced so a partial transport failure cannot become a green Origin/Host result.

Useful? React with 👍 / 👎.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Review

Critical issues

  • Partial transport failure can under-report (aggregateSweep / validatorVerdict). When some variants error in transit but at least one completes-and-rejects and none are accepted, the attempt completes with accepted=false and the detector scores it 0.0 SAFE. Meanwhile validatorVerdict takes its first branch (accepted==0 and rejected present, which ignores the errored count) and prints "every crafted value was refused — validation is enforced," even though the errored variants were never tested — one could be the value the server would accept. Only the all-errored case is promoted to StatusError; the mixed-error case reads as a clean pass, where the old per-variant code surfaced that errored variant as its own error row. Consider marking the sweep inconclusive (MetadataKeyInconclusive) or fixing the verdict text when there are errored variants and none accepted, so a transient error on the decisive variant cannot hide behind a green row — the same reasoning the PR already applies to the all-errored case.

Security

No security concerns flagged. No new external network surface — the probe already sent these crafted Origin/Host values; this only changes how results are folded into attempts. No secrets or auth handling touched.

Test coverage

Excellent — new tests cover the one-finding invariant, evidence completeness, partial validation, all-variants-failed to errored, conditional CORS escalation, and the borrowed-client request count. The mixed-error scoring path noted above is the one case not exercised.

Generated with Claude Code

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gemini Review

No critical issues — LGTM pending human review.


Reviewed by Gemini (gemini-3.1-pro-preview)

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