diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3298a3c..70efbda 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -3,13 +3,23 @@ import logging from pathlib import Path -from quart import Quart +from quart import Quart, request from .api import all_blueprints from .config import get_config from .frontend import frontend_bp from .services.credential_crypto import CredentialCrypto +# Browser-extension origins. The FabledCurator extension fetches from +# moz-extension:/// on Firefox and chrome-extension:/// on +# Chromium-based browsers. Operator-flagged 2026-05-26: extension's +# 'Test connection' returned `NetworkError` because the X-Extension-Key +# header on /api/credentials triggers a CORS preflight that our routes +# don't handle. Whitelisting only these two schemes (not opening CORS +# up generally) lets the extension talk to a plain-HTTP self-hosted FC +# without weakening the no-CORS posture for normal browser usage. +_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://") + _CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64") @@ -35,6 +45,33 @@ def create_app() -> Quart: # Registered last so /api/* routes win over the SPA catch-all. app.register_blueprint(frontend_bp) + @app.before_request + async def _extension_cors_preflight(): + # Short-circuit OPTIONS preflight from the browser extension with a + # 204 + CORS headers (the after_request hook below adds them). + # Without this, OPTIONS lands on routes that only declared POST/GET + # methods and 405s before the after_request gets a chance. + if request.method != "OPTIONS": + return None + origin = request.headers.get("Origin", "") + if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES): + return "", 204 + return None + + @app.after_request + async def _extension_cors_headers(response): + origin = request.headers.get("Origin", "") + if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES): + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PATCH, DELETE, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, X-Extension-Key" + ) + response.headers["Access-Control-Max-Age"] = "86400" + return response + @app.after_serving async def _dispose_db_engine() -> None: from .extensions import dispose_engine diff --git a/tests/test_extension_cors.py b/tests/test_extension_cors.py new file mode 100644 index 0000000..da5c336 --- /dev/null +++ b/tests/test_extension_cors.py @@ -0,0 +1,86 @@ +"""CORS preflight + response headers for moz-extension:// + chrome-extension://. + +Operator-flagged 2026-05-26: extension's first 'Test connection' tap +returned `NetworkError` because /api/credentials had no OPTIONS handler +and no Access-Control-Allow-Origin response header. Browser preflight +failed → fetch blocked. + +These tests pin the contract: any request from a moz-extension:// or +chrome-extension:// origin gets the right ACL headers. Plain browser +requests (no Origin header, or a regular https:// Origin) get nothing +— we don't want to open CORS up generally. +""" + +import pytest + +from backend.app import create_app + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def client(): + app = create_app() + async with app.test_client() as c: + yield c + + +@pytest.mark.asyncio +async def test_extension_preflight_returns_204_with_acl_headers(client): + resp = await client.options( + "/api/credentials", + headers={ + "Origin": "moz-extension://abcd1234-uuid-fake", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "X-Extension-Key", + }, + ) + assert resp.status_code == 204 + assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake" + assert "OPTIONS" in resp.headers["Access-Control-Allow-Methods"] + assert "X-Extension-Key" in resp.headers["Access-Control-Allow-Headers"] + + +@pytest.mark.asyncio +async def test_extension_get_carries_acl_headers(client): + # The actual response (post-preflight) also needs ACL headers — the + # browser checks them again before exposing the response body. + resp = await client.get( + "/api/credentials", + headers={"Origin": "moz-extension://abcd1234-uuid-fake"}, + ) + # 200 with empty list (no creds seeded) — what matters here is the + # CORS header is present. + assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake" + + +@pytest.mark.asyncio +async def test_chrome_extension_origin_also_allowed(client): + resp = await client.options( + "/api/credentials", + headers={ + "Origin": "chrome-extension://abcd1234-uuid-fake", + "Access-Control-Request-Method": "POST", + }, + ) + assert resp.status_code == 204 + assert resp.headers["Access-Control-Allow-Origin"] == "chrome-extension://abcd1234-uuid-fake" + + +@pytest.mark.asyncio +async def test_normal_browser_request_gets_no_cors_headers(client): + # A regular browser tab (https://example.com / file:// / no Origin + # at all) should NOT get any Access-Control-Allow-* — the extension + # whitelist is intentionally narrow. + resp = await client.get( + "/api/credentials", + headers={"Origin": "https://evil.example.com"}, + ) + assert "Access-Control-Allow-Origin" not in resp.headers + + +@pytest.mark.asyncio +async def test_no_origin_header_unaffected(client): + # Same-origin requests (no Origin header) — unaffected. + resp = await client.get("/api/credentials") + assert "Access-Control-Allow-Origin" not in resp.headers