fix(extension): CORS preflight for moz-extension:// + chrome-extension:// origins — operator-flagged 2026-05-26 that the extension's Test connection returned NetworkError because /api/credentials POSTs with X-Extension-Key trigger a browser preflight OPTIONS that hit a 405 (no OPTIONS method registered) with no Access-Control-Allow-* headers. Adds two app-level hooks: before_request short-circuits OPTIONS from extension origins with 204, after_request stamps the necessary ACL headers on responses to extension-origin requests. Whitelist is intentionally narrow (extension schemes only) so normal browser usage doesn't get permissive CORS. Five integration tests pin the contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+38
-1
@@ -3,13 +3,23 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Quart
|
from quart import Quart, request
|
||||||
|
|
||||||
from .api import all_blueprints
|
from .api import all_blueprints
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
from .frontend import frontend_bp
|
from .frontend import frontend_bp
|
||||||
from .services.credential_crypto import CredentialCrypto
|
from .services.credential_crypto import CredentialCrypto
|
||||||
|
|
||||||
|
# Browser-extension origins. The FabledCurator extension fetches from
|
||||||
|
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ 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")
|
_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.
|
# Registered last so /api/* routes win over the SPA catch-all.
|
||||||
app.register_blueprint(frontend_bp)
|
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
|
@app.after_serving
|
||||||
async def _dispose_db_engine() -> None:
|
async def _dispose_db_engine() -> None:
|
||||||
from .extensions import dispose_engine
|
from .extensions import dispose_engine
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user