Skip to content

fix(security): fall back to allowed_origins for CSRF origin validation behind reverse proxies#3437

Merged
crivetimihai merged 4 commits intomainfrom
bug-3431
Mar 6, 2026
Merged

fix(security): fall back to allowed_origins for CSRF origin validation behind reverse proxies#3437
crivetimihai merged 4 commits intomainfrom
bug-3431

Conversation

@jonpspri
Copy link
Copy Markdown
Collaborator

@jonpspri jonpspri commented Mar 3, 2026

Summary

  • Fixes CSRF origin validation failure behind layered reverse proxies where X-Forwarded-Proto / X-Forwarded-Host headers reflect internal hops rather than the external scheme/host
  • Extends _request_origin_matches() to fall back to settings.allowed_origins when the forwarded-header same-origin comparison fails
  • Excludes wildcard entries (*, null, "") from the fallback to preserve fail-closed behavior

Closes #3431

Copilot AI review requested due to automatic review settings March 3, 2026 14:13
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 fixes admin CSRF origin validation in reverse-proxy deployments by allowing a safe fallback comparison against settings.allowed_origins when forwarded-header-based same-origin checks fail (while keeping wildcard origins fail-closed). It also adds unit tests covering the new fallback behavior.

Changes:

  • Extend _request_origin_matches() to fall back to settings.allowed_origins when same-origin (via forwarded headers) does not match.
  • Explicitly exclude wildcard-ish entries (*, null, "") from the fallback.
  • Add unit tests for allowed-origins fallback acceptance/rejection and port normalization behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
mcpgateway/admin.py Adds allowed_origins fallback path to CSRF origin validation when forwarded-header same-origin fails.
tests/unit/mcpgateway/test_admin.py Adds regression tests for the new allowed_origins fallback behavior in enforce_admin_csrf.

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

Comment thread mcpgateway/admin.py Outdated
Comment on lines +1131 to +1134
if allowed in ("*", "null", ""):
continue
try:
allowed_parsed = urllib.parse.urlparse(allowed)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

settings.allowed_origins entries can include leading/trailing whitespace when provided via JSON env (e.g., ["https://a.com "]), and validate_cors_origins doesn’t strip it. In this fallback, a whitespace-padded origin may be silently ignored or mis-parsed, causing CSRF validation to fail unexpectedly. Consider normalizing each allowed entry (e.g., str(...).strip(), plus a case-insensitive check for "null") before the wildcard/parse checks (and ideally precomputing normalized allowed-origin parts once to avoid repeated urlparse work per request).

Suggested change
if allowed in ("*", "null", ""):
continue
try:
allowed_parsed = urllib.parse.urlparse(allowed)
# Normalize each allowed origin to avoid config surprises such as
# ["https://a.com "] or [" null "], which could otherwise be
# mis-parsed or skipped.
allowed_normalized = str(allowed).strip()
if not allowed_normalized or allowed_normalized == "*" or allowed_normalized.casefold() == "null":
continue
try:
allowed_parsed = urllib.parse.urlparse(allowed_normalized)

Copilot uses AI. Check for mistakes.
@jonpspri jonpspri force-pushed the bug-3431 branch 2 times, most recently from 0951747 to 4ea5515 Compare March 3, 2026 14:48
madhav165
madhav165 previously approved these changes Mar 4, 2026
Copy link
Copy Markdown
Collaborator

@madhav165 madhav165 left a comment

Choose a reason for hiding this comment

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

Reviewed the changes — code looks good. The allowed_origins fallback in _request_origin_matches() is clean, wildcard/null exclusion preserves fail-closed behavior, and test coverage is solid (6 new cases covering accept, reject, port normalization, empty set, and regression). Nginx header pass-through changes are consistent across all location blocks.

@madhav165 madhav165 added the release-fix Critical bugfix required for the release label Mar 5, 2026
@crivetimihai crivetimihai added bug Something isn't working security Improves security MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe labels Mar 5, 2026
@crivetimihai crivetimihai added this to the Release 1.0.0-RC2 milestone Mar 5, 2026
@crivetimihai
Copy link
Copy Markdown
Member

Thanks @jonpspri — critical fix for #3431. The fallback to allowed_origins when forwarded-header comparison fails is the right approach for reverse proxy deployments. Good that wildcard entries are excluded from the fallback to maintain fail-closed behavior. LGTM.

jonpspri and others added 3 commits March 6, 2026 11:15
…gh for reverse-proxy deployments (#3431)

Behind layered reverse proxies, X-Forwarded-Proto / X-Forwarded-Host
headers may reflect internal hops rather than the external scheme,
causing the strict same-origin CSRF check to reject legitimate admin
mutations.

- Extend _request_origin_matches() to fall back to settings.allowed_origins
  when the forwarded-header comparison fails; wildcard entries (*, null, "")
  are excluded to preserve fail-closed behavior
- Normalize allowed_origins entries (strip whitespace, case-insensitive null)
- Add map block to nginx.conf and Helm nginx configmap to preserve upstream
  X-Forwarded-Proto instead of overwriting with $scheme; pass X-Forwarded-Host

Closes #3431

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…ll/blank tests

- Prepend https:// for schemeless allowed_origins entries in CSRF
  fallback, consistent with sso.py:_validate_redirect_uri()
- Add tests for null, blank, and schemeless allowed_origins entries
- Rename test to reflect updated schemeless behavior

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
crivetimihai
crivetimihai previously approved these changes Mar 6, 2026
Copy link
Copy Markdown
Member

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

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

Review — Approve

Rebased onto main (clean, no conflicts) and reviewed thoroughly.

What this PR does

  1. Nginx configs: Preserves upstream X-Forwarded-Proto via map directive instead of overwriting with $scheme. Adds X-Forwarded-Host $http_host. Applied to both nginx.conf and the Helm configmap.
  2. CSRF origin fallback (admin.py): When the forwarded-header same-origin check fails, falls back to settings.allowed_origins. Wildcards (*, null, "") are excluded to preserve fail-closed behavior.
  3. Tests: 12 new tests covering all fallback branches.

Changes I made on top of the original commits

  • Aligned schemeless origin handling with SSO: _request_origin_matches() now prepends https:// for allowed_origins entries without a scheme (e.g. bare example.com), consistent with sso.py:_validate_redirect_uri(). Previously these were silently skipped, meaning ALLOWED_ORIGINS=example.com would work for SSO but not for CSRF fallback.
  • Added 3 new tests: null entry rejection, blank "" entry rejection, and schemeless origin positive-path acceptance.
  • Renamed test: test_enforce_admin_csrf_skips_schemeless_allowed_origintest_enforce_admin_csrf_rejects_non_matching_bare_host with updated description to reflect the new behavior.

Security analysis

  • No CSRF bypass: fallback requires exact normalized match against non-wildcard allowlist entries, and the double-submit CSRF token check still gates all mutations independently.
  • Wildcard/null/blank filtering is correct and tested.
  • Fail-closed behavior preserved: empty allowed_origins or only-wildcard sets result in rejection.

Out-of-scope follow-ups

  • Other nginx configs (nginx-tls.conf, nginx-performance.conf, tls-stack.yaml) still use $scheme — should be addressed in a separate PR.
  • The map directive trusts inbound X-Forwarded-Proto unconditionally — pre-existing architectural concern, not introduced by this PR.

…ins fallback

Adds test for allowed_origins entry '://' which has the scheme separator
but produces empty scheme/netloc after parsing, exercising the defensive
guard at admin.py:1139-1140.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
@crivetimihai crivetimihai merged commit 12735ab into main Mar 6, 2026
39 checks passed
@crivetimihai crivetimihai deleted the bug-3431 branch March 6, 2026 13:58
@crivetimihai crivetimihai self-assigned this Mar 9, 2026
MohanLaksh pushed a commit that referenced this pull request Mar 12, 2026
…n behind reverse proxies (#3437)

* fix(security): CSRF origin validation and forwarded-header pass-through for reverse-proxy deployments (#3431)

Behind layered reverse proxies, X-Forwarded-Proto / X-Forwarded-Host
headers may reflect internal hops rather than the external scheme,
causing the strict same-origin CSRF check to reject legitimate admin
mutations.

- Extend _request_origin_matches() to fall back to settings.allowed_origins
  when the forwarded-header comparison fails; wildcard entries (*, null, "")
  are excluded to preserve fail-closed behavior
- Normalize allowed_origins entries (strip whitespace, case-insensitive null)
- Add map block to nginx.conf and Helm nginx configmap to preserve upstream
  X-Forwarded-Proto instead of overwriting with $scheme; pass X-Forwarded-Host

Closes #3431

Signed-off-by: Jonathan Springer <jps@s390x.com>

* improve coverage

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* fix(security): align CSRF allowed_origins parsing with SSO and add null/blank tests

- Prepend https:// for schemeless allowed_origins entries in CSRF
  fallback, consistent with sso.py:_validate_redirect_uri()
- Add tests for null, blank, and schemeless allowed_origins entries
- Rename test to reflect updated schemeless behavior

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* test(security): cover empty-scheme-netloc branch in CSRF allowed_origins fallback

Adds test for allowed_origins entry '://' which has the scheme separator
but produces empty scheme/netloc after parsing, exercising the defensive
guard at admin.py:1139-1140.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Co-authored-by: Madhav Kandukuri <madhav165@gmail.com>
Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
Yosiefeyob pushed a commit that referenced this pull request Mar 13, 2026
…n behind reverse proxies (#3437)

* fix(security): CSRF origin validation and forwarded-header pass-through for reverse-proxy deployments (#3431)

Behind layered reverse proxies, X-Forwarded-Proto / X-Forwarded-Host
headers may reflect internal hops rather than the external scheme,
causing the strict same-origin CSRF check to reject legitimate admin
mutations.

- Extend _request_origin_matches() to fall back to settings.allowed_origins
  when the forwarded-header comparison fails; wildcard entries (*, null, "")
  are excluded to preserve fail-closed behavior
- Normalize allowed_origins entries (strip whitespace, case-insensitive null)
- Add map block to nginx.conf and Helm nginx configmap to preserve upstream
  X-Forwarded-Proto instead of overwriting with $scheme; pass X-Forwarded-Host

Closes #3431

Signed-off-by: Jonathan Springer <jps@s390x.com>

* improve coverage

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* fix(security): align CSRF allowed_origins parsing with SSO and add null/blank tests

- Prepend https:// for schemeless allowed_origins entries in CSRF
  fallback, consistent with sso.py:_validate_redirect_uri()
- Add tests for null, blank, and schemeless allowed_origins entries
- Rename test to reflect updated schemeless behavior

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* test(security): cover empty-scheme-netloc branch in CSRF allowed_origins fallback

Adds test for allowed_origins entry '://' which has the scheme separator
but produces empty scheme/netloc after parsing, exercising the defensive
guard at admin.py:1139-1140.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

---------

Signed-off-by: Jonathan Springer <jps@s390x.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Co-authored-by: Madhav Kandukuri <madhav165@gmail.com>
Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
Signed-off-by: Yosief Eyob <yosiefogbazion@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe release-fix Critical bugfix required for the release security Improves security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][API]: CSRF origin validation blocks server deletion in multi-pod deployments

4 participants