fix(security): fall back to allowed_origins for CSRF origin validation behind reverse proxies#3437
fix(security): fall back to allowed_origins for CSRF origin validation behind reverse proxies#3437crivetimihai merged 4 commits intomainfrom
Conversation
There was a problem hiding this comment.
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 tosettings.allowed_originswhen 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.
| if allowed in ("*", "null", ""): | ||
| continue | ||
| try: | ||
| allowed_parsed = urllib.parse.urlparse(allowed) |
There was a problem hiding this comment.
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).
| 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) |
0951747 to
4ea5515
Compare
madhav165
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
Review — Approve
Rebased onto main (clean, no conflicts) and reviewed thoroughly.
What this PR does
- Nginx configs: Preserves upstream
X-Forwarded-Protoviamapdirective instead of overwriting with$scheme. AddsX-Forwarded-Host $http_host. Applied to bothnginx.confand the Helm configmap. - CSRF origin fallback (
admin.py): When the forwarded-header same-origin check fails, falls back tosettings.allowed_origins. Wildcards (*,null,"") are excluded to preserve fail-closed behavior. - 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 prependshttps://forallowed_originsentries without a scheme (e.g. bareexample.com), consistent withsso.py:_validate_redirect_uri(). Previously these were silently skipped, meaningALLOWED_ORIGINS=example.comwould work for SSO but not for CSRF fallback. - Added 3 new tests:
nullentry rejection, blank""entry rejection, and schemeless origin positive-path acceptance. - Renamed test:
test_enforce_admin_csrf_skips_schemeless_allowed_origin→test_enforce_admin_csrf_rejects_non_matching_bare_hostwith 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_originsor 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
mapdirective trusts inboundX-Forwarded-Protounconditionally — 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>
…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>
…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>
Summary
X-Forwarded-Proto/X-Forwarded-Hostheaders reflect internal hops rather than the external scheme/host_request_origin_matches()to fall back tosettings.allowed_originswhen the forwarded-header same-origin comparison fails*,null,"") from the fallback to preserve fail-closed behaviorCloses #3431