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:
2026-05-26 19:43:31 -04:00
parent f827612930
commit c7001f4aed
2 changed files with 124 additions and 1 deletions
+86
View File
@@ -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