audio streaming app plyr.fm

fix: CORS regex rejects plyr.fm subdomains like docs.plyr.fm (#1034)

the production regex only allowed `plyr.fm` and `www.plyr.fm` — any
other subdomain (e.g. `docs.plyr.fm`) got blocked. widen the pattern
to `([a-z0-9-]+\.)?plyr\.fm` so all first-party subdomains work.

same fix applied to the staging regex.

adds regression tests for CORS origin matching across all environments.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
5a91753b 94cfc90d

+102 -4
+4 -4
backend/src/backend/config.py
··· 219 219 hostname = parsed.hostname or "localhost" 220 220 221 221 if hostname == "stg.plyr.fm": 222 - # staging: allow stg.plyr.fm 223 - return r"^(https://stg\.plyr\.fm|https://([a-z0-9]+\.)?relay-4i6\.pages\.dev|http://localhost:5173)$" 222 + # staging: allow stg.plyr.fm and *.stg.plyr.fm subdomains 223 + return r"^(https://([a-z0-9-]+\.)?stg\.plyr\.fm|https://([a-z0-9]+\.)?relay-4i6\.pages\.dev|http://localhost:5173)$" 224 224 elif hostname in ("plyr.fm", "www.plyr.fm"): 225 - # production: allow plyr.fm, www.plyr.fm, and embed consumers 226 - return r"^(https://(www\.)?plyr\.fm|https://zzstoatzz\.(github\.io|io)|https://([a-z0-9]+\.)?relay-4i6\.pages\.dev|http://localhost:5173)$" 225 + # production: allow plyr.fm, *.plyr.fm subdomains, and embed consumers 226 + return r"^(https://([a-z0-9-]+\.)?plyr\.fm|https://zzstoatzz\.(github\.io|io)|https://([a-z0-9]+\.)?relay-4i6\.pages\.dev|http://localhost:5173)$" 227 227 else: 228 228 # local dev: allow localhost 229 229 return r"^(http://localhost:5173)$"
+98
backend/tests/test_cors_origins.py
··· 1 + import os 2 + import re 3 + 4 + import pytest 5 + 6 + from backend.config import FrontendSettings 7 + 8 + 9 + def _make_settings(frontend_url: str) -> FrontendSettings: 10 + """create FrontendSettings with the given FRONTEND_URL.""" 11 + env = os.environ.copy() 12 + os.environ["FRONTEND_URL"] = frontend_url 13 + try: 14 + return FrontendSettings() 15 + finally: 16 + os.environ.clear() 17 + os.environ.update(env) 18 + 19 + 20 + def _matches(pattern: str, origin: str) -> bool: 21 + return re.match(pattern, origin) is not None 22 + 23 + 24 + class TestProductionCorsOrigins: 25 + """CORS regex for production (FRONTEND_URL=https://plyr.fm).""" 26 + 27 + @pytest.fixture 28 + def regex(self) -> str: 29 + return _make_settings("https://plyr.fm").resolved_cors_origin_regex 30 + 31 + @pytest.mark.parametrize( 32 + "origin", 33 + [ 34 + "https://plyr.fm", 35 + "https://www.plyr.fm", 36 + "https://docs.plyr.fm", 37 + "https://stg.plyr.fm", 38 + "https://zzstoatzz.github.io", 39 + "https://zzstoatzz.io", 40 + "http://localhost:5173", 41 + ], 42 + ) 43 + def test_allowed(self, regex: str, origin: str) -> None: 44 + assert _matches(regex, origin), f"{origin} should be allowed" 45 + 46 + @pytest.mark.parametrize( 47 + "origin", 48 + [ 49 + "https://evil-plyr.fm", 50 + "https://notplyr.fm", 51 + "http://plyr.fm", 52 + ], 53 + ) 54 + def test_rejected(self, regex: str, origin: str) -> None: 55 + assert not _matches(regex, origin), f"{origin} should be rejected" 56 + 57 + 58 + class TestStagingCorsOrigins: 59 + """CORS regex for staging (FRONTEND_URL=https://stg.plyr.fm).""" 60 + 61 + @pytest.fixture 62 + def regex(self) -> str: 63 + return _make_settings("https://stg.plyr.fm").resolved_cors_origin_regex 64 + 65 + @pytest.mark.parametrize( 66 + "origin", 67 + [ 68 + "https://stg.plyr.fm", 69 + "https://docs.stg.plyr.fm", 70 + "http://localhost:5173", 71 + ], 72 + ) 73 + def test_allowed(self, regex: str, origin: str) -> None: 74 + assert _matches(regex, origin), f"{origin} should be allowed" 75 + 76 + @pytest.mark.parametrize( 77 + "origin", 78 + [ 79 + "https://plyr.fm", 80 + "https://evil-stg.plyr.fm", 81 + ], 82 + ) 83 + def test_rejected(self, regex: str, origin: str) -> None: 84 + assert not _matches(regex, origin), f"{origin} should be rejected" 85 + 86 + 87 + class TestLocalDevCorsOrigins: 88 + """CORS regex for local dev (FRONTEND_URL=http://localhost:5173).""" 89 + 90 + @pytest.fixture 91 + def regex(self) -> str: 92 + return _make_settings("http://localhost:5173").resolved_cors_origin_regex 93 + 94 + def test_allows_localhost(self, regex: str) -> None: 95 + assert _matches(regex, "http://localhost:5173") 96 + 97 + def test_rejects_remote(self, regex: str) -> None: 98 + assert not _matches(regex, "https://plyr.fm")