Back to Blog
critical SEVERITY7 min read

How CSRF Protection Failures Happen in FastAPI and How to Fix Them

A critical CORS misconfiguration in `backend/main.py` allowed cookies to be sent alongside wildcard-origin requests, violating the CORS specification and opening the door to cross-site request forgery attacks. The fix conditionally disables `allow_credentials` when the allowed origins list contains a wildcard, bringing the configuration into compliance with browser security rules. This change closes a subtle but dangerous gap that could have let attackers on sibling subdomains forge authenticate

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This vulnerability is a CSRF/CORS misconfiguration (CWE-352) in a Python FastAPI application. The `CORSMiddleware` in `backend/main.py` was configured with both `allow_origins=["*"]` and `allow_credentials=True` simultaneously — a combination the CORS specification explicitly forbids and browsers reject, while also creating a permissive security posture for same-site attacks. The fix introduces a computed boolean `_allow_credentials` that disables credential sharing whenever a wildcard origin is present, ensuring the application never advertises credential-bearing cross-origin access to untrusted origins.

Vulnerability at a Glance

cweCWE-352
fixCompute `_allow_credentials` dynamically — only `True` when no wildcard origin is present
riskAttackers on sibling subdomains can forge authenticated state-changing requests using the victim's session cookies
languagePython
root cause`allow_credentials=True` hardcoded alongside `allow_origins=["*"]` in FastAPI CORSMiddleware
vulnerabilityCSRF via permissive CORS configuration

How CSRF Protection Failures Happen in FastAPI and How to Fix Them

The backend/main.py file is the front door of this web application — it handles routing, middleware, and the security boundaries that separate legitimate users from attackers. But a subtle misconfiguration in its CORS middleware setup created a real-world exploitable path for cross-site request forgery (CSRF) attacks. This post walks through exactly what went wrong, how it could be exploited, and the precise change that closed the gap.


The Vulnerability Explained

A CORS Rule You Cannot Break

The CORS specification is unambiguous on one point: you cannot combine a wildcard origin (*) with credentialed requests. When allow_credentials=True is set, the browser requires the server to respond with a specific origin (e.g., https://notes.example.com) in the Access-Control-Allow-Origin header — not a wildcard. Browsers will outright reject the combination.

Here is the vulnerable configuration that existed in backend/main.py around line 204:

# BEFORE (vulnerable)
allowed_origins = config.get('server', {}).get('allowed_origins', ["*"])
app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,   # defaults to ["*"]
    allow_credentials=True,          # hardcoded — always True
    allow_methods=["*"],
    allow_headers=["*"],
)

The problem is the hardcoded allow_credentials=True. When the application is deployed with the default configuration (i.e., allowed_origins is not explicitly set in config.yaml), allowed_origins becomes ["*"]. At that point:

  1. The CORS spec forbids the combination — browsers will block the preflight.
  2. More dangerously, the intent of the developer was clearly to allow credentials, meaning session cookies are expected to be sent with cross-origin requests.
  3. If an operator later restricts allowed_origins to specific domains but forgets to audit allow_credentials, the credential-bearing behavior silently persists.

Why SameSite=Lax Isn't a Complete Defense

The application uses session cookies with SameSite=Lax. This setting blocks cross-origin POST requests from third-party sites — but it has two important blind spots:

  • Same-site attacks: A subdomain like evil.example.com is considered "same-site" relative to notes.example.com. An attacker who can serve content on any subdomain of the same registrable domain can submit POST requests that do include the victim's session cookie.
  • Top-level navigation GET requests: SameSite=Lax permits cookies on GET requests triggered by top-level navigation (e.g., clicking a link), which means any endpoint that changes state via GET is still vulnerable.

The Attack Scenario

Imagine this application is deployed at app.company.com. An attacker finds or creates a page at promo.company.com (a marketing subdomain with less rigorous security). They embed the following in that page:

<form method="POST" action="https://app.company.com/api/admin/delete-user">
  <input type="hidden" name="user_id" value="42" />
</form>
<script>document.forms[0].submit();</script>

Because promo.company.com and app.company.com share the company.com registrable domain, the browser treats this as a same-site request and includes the session cookie. The server receives a fully authenticated DELETE request that the user never intended to make.


The Fix

The fix is elegant in its simplicity. Instead of hardcoding allow_credentials=True, the value is now computed based on whether the origins list contains a wildcard:

# AFTER (fixed)
allowed_origins = config.get('server', {}).get('allowed_origins', ["*"])

# Credentials must not be sent with wildcard origins (CORS spec disallows it and
# browsers reject it; explicitly disable to avoid misconfiguration).
_allow_credentials = "*" not in allowed_origins

app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,
    allow_credentials=_allow_credentials,  # dynamically computed
    allow_methods=["*"],
    allow_headers=["*"],
)

Before vs. After

Configuration State Before After
allowed_origins = ["*"] (default) allow_credentials=True allow_credentials=False
allowed_origins = ["https://app.example.com"] allow_credentials=True allow_credentials=True

Why This Change Works

The single line _allow_credentials = "*" not in allowed_origins encodes the CORS specification's own rule directly into the middleware configuration. It makes two guarantees:

  1. Wildcard deployments are safe by default. When no explicit origins are configured, credentials are disabled. An unauthenticated CORS policy is still useful for public API endpoints that don't require session state.
  2. Explicit origin lists preserve authenticated behavior. Operators who restrict allowed_origins to known domains (the secure, production-ready configuration) automatically get credential support without any additional changes.

This is a defense-in-depth improvement: it doesn't replace CSRF tokens (which remain the gold standard for protecting state-changing endpoints), but it eliminates a configuration path that was both spec-violating and operationally dangerous.


Key Takeaways

  • allow_credentials=True with allow_origins=["*"] is never safe — it violates the CORS specification and creates a misleading security posture, even if browsers technically reject the combination.
  • SameSite=Lax is not CSRF-proof — same-site subdomain attacks bypass it entirely, which is especially relevant for multi-tenant deployments on shared domains.
  • Default configurations are attack surfaces — the config.get('server', {}).get('allowed_origins', ["*"]) pattern means the wildcard is the default behavior, making hardcoded allow_credentials=True dangerous for any operator who doesn't read the docs.
  • Derive security settings from other security settings — the fix's _allow_credentials = "*" not in allowed_origins pattern is a great model: make the safe behavior automatic rather than requiring operators to configure two related values consistently.
  • CORS misconfiguration and CSRF are distinct but related — fixing CORS doesn't eliminate CSRF risk; CSRF tokens on state-changing endpoints remain the defense of last resort.

How Orbis AppSec Detected This

  • Source: The allowed_origins variable, populated from config.yaml with a default of ["*"], fed directly into the CORSMiddleware constructor in backend/main.py.
  • Sink: The allow_credentials=True hardcoded argument to app.add_middleware(CORSMiddleware, ...) at line 204 of backend/main.py, which unconditionally advertised credential-bearing cross-origin access regardless of the origin policy.
  • Missing control: No conditional logic existed to disable allow_credentials when the origins list was a wildcard; the two settings were configured independently with no enforcement of the CORS specification's mutual exclusivity rule.
  • CWE: CWE-352 — Cross-Site Request Forgery
  • Fix: A single computed boolean _allow_credentials = "*" not in allowed_origins was introduced and substituted for the hardcoded True, making credential-bearing CORS access contingent on an explicit, non-wildcard origin allowlist.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

This vulnerability is a textbook example of how two individually reasonable-seeming configuration choices — "allow all origins for self-hosted simplicity" and "enable credentials for session-based auth" — combine into a dangerous misconfiguration. The fix required exactly one new line of code and zero changes to application logic, yet it closes a real attack path against same-site subdomain attackers.

For developers building FastAPI applications with session-based authentication, the lesson is clear: treat your CORS and cookie configurations as a system, not independent knobs. When you change one, audit the other. And when in doubt, default to the most restrictive behavior — it's much easier to loosen security for a specific deployment than to patch a breach.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #278

Related Articles

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

high

How Denial of Service via Infinite Loop Happens in JavaScript Dependencies and How to Fix It

CVE-2026-67213 is a high-severity denial of service vulnerability in nanoid before version 5.1.6 that triggers an infinite loop during random ID generation when processing specially crafted input. We upgraded nanoid across the entire dependency tree to patch this flaw and prevent attackers from freezing application threads. This fix ensures that ID generation remains resilient even when handling adversarial input patterns.

high

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.