diff --git a/docs/radio.md b/docs/radio.md index 6dfeaeb..2d6ee05 100644 --- a/docs/radio.md +++ b/docs/radio.md @@ -25,9 +25,16 @@ Endpoints (`radio/urls.py`): the featured stations come rendered into it (`#saved-tbody`, `#history-tbody`, `.featured-section`) and as `INITIAL_SAVED` (with `play_count`), `INITIAL_FEATURED`, `USER_FOCUS_STATION`. The apps take these parts from it (see below). -- `GET /radio/sse/?url=` — server-sent events with the track title, `data: {"track": …}` when it +- `GET /radio/sse/?url=&station=` — server-sent events with the track title, `data: {"track": …}` when it changes, `{"error": "stream ended"}` at the end. The server opens the stream itself with - `Icy-MetaData: 1` and reads `StreamTitle` (`radio/icy.py`). + `Icy-MetaData: 1` and reads `StreamTitle` (`radio/icy.py`). Which addresses it opens + (`radio/streamcheck.py`, since 2026-10-24): never one in a private network (loopback, + 10.x, 172.16–31.x, 192.168.x, link-local, the Docker network); **without an account only the + directory's stations** — the page's curated lists (read from `16-stations.js`), the featured + stations, the creamfresh stream, and what radio-browser.info lists (`station=` its station + id, which the page sends for search results; else by the address; kept a day). An address of + one's own is for saving, and saving needs an account: signed out, a pasted address offers + signing in instead of ▶. - `POST /radio/record/` — a track into the history; `scrobble: true` sends it to Last.fm when the account has it linked (`radio/lastfm.py`; "Artist - Title", else the station as artist). - `POST /radio/save/` (by url: create or update), `/radio/remove//`, `/radio/favorite//`, @@ -124,8 +131,6 @@ login; `backgroundThrottling: false` keeps the titles coming. ## Known weak spots -- `/radio/sse/` needs no login and connects to any address it is given — a way to make the - server fetch arbitrary URLs. Should require a session (or a saved/featured station). - The login for the creamfresh stream is written into `radio/views.py`; it belongs in the environment like the other secrets. - Every listener's titles cost the server a second connection to the stream. diff --git a/radio/streamcheck.py b/radio/streamcheck.py new file mode 100644 index 0000000..ad59e5d --- /dev/null +++ b/radio/streamcheck.py @@ -0,0 +1,84 @@ +"""Which stream addresses the server may open for track titles (/radio/sse/, docs/radio.md). + +- Never an address inside a private network (loopback, 10.x, 172.16-31.x, 192.168.x, + link-local, the Docker network …): the server would otherwise reach what only it can see. +- Without an account only the directory's stations: the page's curated lists, the featured + ones, and those radio-browser.info lists (by its station id, or by the address). Bringing an address of + one's own is for saving it, and saving needs an account. +""" +import functools +import hashlib +import ipaddress +import re +import socket +from pathlib import Path +from urllib.parse import urlparse + +import requests +from django.core.cache import cache + +from .models import FeaturedStation + +DIRECTORY = 'https://de1.api.radio-browser.info/json/stations' +# The page's curated lists (SomaFM, SWR2 …) are part of the directory too; their addresses +# are read from where the page has them, so they stand in one place only. +CURATED_JS = Path(__file__).resolve().parent.parent / 'static' / 'js' / 'app' / '16-stations.js' +DIRECTORY_TIMEOUT = 6 +DIRECTORY_CACHE = 24 * 3600 + + +def public_address(url): + """True when url is http(s) and every address its host resolves to is a public one.""" + try: + parsed = urlparse(url) + except ValueError: + return False + if parsed.scheme not in ('http', 'https') or not parsed.hostname: + return False + try: + infos = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == 'https' else 80), + proto=socket.IPPROTO_TCP) + except (OSError, UnicodeError, ValueError): + return False + addresses = {info[4][0] for info in infos} + return bool(addresses) and all(ipaddress.ip_address(a.split('%')[0]).is_global for a in addresses) + + +def _directory_lists(url, uuid): + if uuid: + try: + r = requests.get(f'{DIRECTORY}/byuuid/{uuid}', timeout=DIRECTORY_TIMEOUT, + headers={'User-Agent': 'diora/1.0'}) + if r.ok and any(url in (s.get('url'), s.get('url_resolved')) for s in r.json()): + return True + except (requests.RequestException, ValueError): + pass + try: + r = requests.get(f'{DIRECTORY}/byurl', params={'url': url}, timeout=DIRECTORY_TIMEOUT, + headers={'User-Agent': 'diora/1.0'}) + return bool(r.ok and r.json()) + except (requests.RequestException, ValueError): + return False + + +@functools.lru_cache(maxsize=1) +def curated(): + try: + return frozenset(re.findall(r"url: '([^']+)'", CURATED_JS.read_text(encoding='utf-8'))) + except OSError: + return frozenset() + + +def in_directory(url, uuid='', own_host=''): + """A station of the directory: curated on the page, featured, ours (the creamfresh stream + through this server), or listed by radio-browser.info. Answers are kept a day.""" + if url in curated() or FeaturedStation.objects.filter(url=url, active=True).exists(): + return True + if own_host and urlparse(url).netloc == own_host: + return True + key = 'radio:dir:' + hashlib.sha1(f'{url} {uuid}'.encode()).hexdigest() + known = cache.get(key) + if known is None: + known = _directory_lists(url, uuid) + cache.set(key, known, DIRECTORY_CACHE) + return known diff --git a/radio/tests.py b/radio/tests.py index 204d77c..813f4fd 100644 --- a/radio/tests.py +++ b/radio/tests.py @@ -38,3 +38,63 @@ class PlayerPagePodcastsTests(TestCase): sw = (static / 'js' / 'sw.js').read_text(encoding='utf-8') for path in ('/static/js/podcasts-web.js', '/static/podcasts/native-podcast.js'): self.assertIn(path, sw) + + +class SseGuardTests(TestCase): + """/radio/sse/ opens a stream for its titles: never in a private network, and without an + account only the directory's stations (radio/streamcheck.py, docs/radio.md).""" + + PUBLIC = [(2, 1, 6, '', ('93.184.216.34', 80))] + STREAM = 'http://stream.example.org/live.mp3' + + def setUp(self): + from django.core.cache import cache + cache.clear() + + def sse(self, url, **more): + from unittest import mock + with mock.patch('radio.views.stream_icy_metadata', return_value=iter(['Artist - Title'])): + return self.client.get('/radio/sse/', {'url': url, **more}) + + def test_private_addresses_never(self): + User.objects.create_user('r', password='pw12345678') + self.client.login(username='r', password='pw12345678') + for url in ['http://127.0.0.1:8000/', 'http://10.0.0.5/stream', 'http://169.254.169.254/latest/', + 'http://[::1]/x', 'file:///etc/passwd', 'gopher://example.org/']: + self.assertEqual(self.sse(url).status_code, 403, url) + + def test_without_account_only_the_directory(self): + from unittest import mock + with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC): + with mock.patch('radio.streamcheck.requests.get') as get: + get.return_value = mock.Mock(ok=True, json=lambda: []) + self.assertEqual(self.sse(self.STREAM).status_code, 403) # not listed: an own address + listed = [{'url': 'http://stream.example.org/pls', 'url_resolved': self.STREAM}] + get.return_value = mock.Mock(ok=True, json=lambda: listed) + self.assertEqual(self.sse(self.STREAM, station='abc-uuid').status_code, 200) + + def test_featured_stations_without_account(self): + from unittest import mock + from .models import FeaturedStation + FeaturedStation.objects.create(name='F', url=self.STREAM) + with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), \ + mock.patch('radio.streamcheck.requests.get') as get: + self.assertEqual(self.sse(self.STREAM).status_code, 200) + get.assert_not_called() + + def test_own_addresses_with_an_account(self): + from unittest import mock + User.objects.create_user('r', password='pw12345678') + self.client.login(username='r', password='pw12345678') + with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), \ + mock.patch('radio.streamcheck.requests.get') as get: + self.assertEqual(self.sse(self.STREAM).status_code, 200) + get.assert_not_called() + + def test_the_pages_curated_stations_without_account(self): + from unittest import mock + from . import streamcheck + self.assertIn('https://ice6.somafm.com/dronezone-256-mp3', streamcheck.curated()) + with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), mock.patch('radio.streamcheck.requests.get') as get: + self.assertEqual(self.sse('https://ice6.somafm.com/dronezone-256-mp3').status_code, 200) + get.assert_not_called() diff --git a/radio/views.py b/radio/views.py index c3a7d90..8453d17 100644 --- a/radio/views.py +++ b/radio/views.py @@ -12,6 +12,7 @@ from django.conf import settings from django.http import ( HttpResponse, HttpResponseBadRequest, + HttpResponseForbidden, JsonResponse, StreamingHttpResponse, ) @@ -22,6 +23,7 @@ from django.views.decorators.http import require_http_methods from .icy import stream_icy_metadata from . import lastfm as lastfm_module +from . import streamcheck from .models import SavedStation, StationPlay, TrackHistory, FocusSession, FeaturedStation @@ -98,9 +100,16 @@ def index(request): @csrf_exempt def sse_metadata(request): + """The stream's track titles. Never from a private network; without an account only for + the directory's stations (radio/streamcheck.py, docs/radio.md).""" url = request.GET.get('url', '').strip() if not url: return HttpResponseBadRequest('url parameter required') + if not streamcheck.public_address(url): + return HttpResponseForbidden('not a public stream address') + if not request.user.is_authenticated and not streamcheck.in_directory( + url, request.GET.get('station', '').strip()[:64], request.get_host()): + return HttpResponseForbidden('own stream addresses need an account') def event_stream(): last_title = None diff --git a/static/js/app/10-radio.js b/static/js/app/10-radio.js index 6a2cf67..38cc6ac 100644 --- a/static/js/app/10-radio.js +++ b/static/js/app/10-radio.js @@ -235,7 +235,11 @@ if (volWheelTarget) { function startMetadataSSE(streamUrl) { if (sseSource) { sseSource.close(); sseSource = null; } - const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl); + // A station from the directory says which (radio-browser's id): without an account the + // server opens only the directory's stations (radio/streamcheck.py). + const dir = window._directoryStation; + const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl) + + (dir && dir.url === streamUrl && dir.uuid ? '&station=' + encodeURIComponent(dir.uuid) : ''); sseSource = new EventSource(endpoint); sseSource.onmessage = function (e) { @@ -421,6 +425,7 @@ async function doSearch() { country: st.country || '', tags: st.tags || '', favicon_url: st.favicon || '', + uuid: st.stationuuid || '', })})'> ▶ Abspielen @@ -470,6 +475,16 @@ function showStreamAddressResult(url) { tr.children[3].title = shown; const actions = document.createElement('td'); + // An address of one's own is there to be saved, and saving needs an account; without one + // the directory is what plays (docs/radio.md). + if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) { + actions.innerHTML = 'Eigene Adressen mit Konto: Anmelden oder ' + + 'registrieren'; + tr.appendChild(actions); + tbody.appendChild(tr); + tableEl.style.display = ''; + return; + } const play = document.createElement('button'); play.className = 'btn btn-sm btn-play'; play.innerHTML = '▶ Abspielen'; @@ -495,6 +510,7 @@ function showStreamAddressResult(url) { function searchPlay(url, name, stationData) { // Store station data on the window so saveCurrentStation() can use it window._pendingStationData = stationData; + window._directoryStation = stationData && stationData.uuid ? {url, uuid: stationData.uuid} : null; playStation(url, name, null); } diff --git a/static/js/sw.js b/static/js/sw.js index 069a0c2..de43dc5 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v145'; +const CACHE = 'diora-v146'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',