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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.

critical

No Rate Limit on /api/uploads/presign Enables DoS

The `/api/uploads/presign` endpoint accepted unlimited concurrent requests to generate storage presigned URLs, giving an attacker a free lever to exhaust storage-provider quotas and server resources. The fix adds an `express-rate-limit` middleware capping each client to 30 requests per minute on that route.

high

CVE-2026-54673: builder-util-runtime Leaks Auth Headers on Redirect

electron-updater and electron-builder rely on builder-util-runtime to fetch update manifests and artifacts over HTTP. A flaw in that shared HTTP executor allowed credential headers attached to the original update-feed request to be re-sent after a redirect, exposing them to any host the redirect pointed to. The project fixes this by upgrading builder-util-runtime to 9.7.0 and collapsing a duplicate, older copy of the package that electron-updater had pinned on its own.