fix(orchestrator): release a station's redirect targets in media-src
Deutschlandfunk stayed silent: its signed URL answers 302 to f111.rndfnk.com, an origin the signed media-src never listed, so the browser blocked the redirected load and reported only the station's own URL in securitypolicyviolation. The hub now resolves each https stream's redirect chain server side when a settings save adds or changes it and stores the extra origins as the stream's via list. media-src lists them next to the signed origins. A CDN host of three or more labels widens to its siblings (*.rndfnk.com); an IP, a two-label host and a name under a country code's generic second level stay exact. Hardening: - Only the signed URL itself may lie at a non-global address; every redirect hop dials a global address only, checked on the address actually dialed (NAT64 and IPv4-compatible IPv6 count by the embedded IPv4 address). - A resolve answers within three socket timeouts even for a server that drips its headers; a late probe goes on in a daemon thread and a url still in flight is not probed twice. - A failed resolution stores via null and retries on a save ten minutes later; the radio page says so instead of blaming the CSP. - A save whose settings changed while it was resolved fails instead of overwriting them (compare and set under the settings lock). - A new stream URL at an already signed origin takes the passkey too. - Settings written before via existed load as via [] and never dial. Residuals: - The passkey proof is spent before the resolve, so a save racing another one fails and the operator signs again. - A save that hits a due retry blocks up to twelve seconds and may reload the page. - A page loaded before the upgrade throws on the missing via until it reloads. - A private multi-tenant suffix such as github.io still widens. - getaddrinfo has no timeout; it runs inside the daemon thread. Operator step after install: remove Deutschlandfunk in Radio, Sender bearbeiten, save, add it again and save with the passkey. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 88 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46382cee9a
commit
ba97f7cc2e
18 files changed
+899
-58
No files matched your search
@@ -662,7 +662,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
|
||||
def speaks(self, cues: tuple[str, ...]) -> None:
|
||||
hub = self.httpd.hub
|
||||
hub.settings.put({**hub.settings.get(), "speech": {"on": True, "classes": ["mayday", "pan-pan", "security"], "muted": []}})
|
||||
hub.settings.put({**hub.settings.get(), "speech": {"on": True, "classes": ["mayday", "pan-pan", "security"], "muted": []}}, hub.settings.get())
|
||||
spoken = tone(6, 8000)
|
||||
|
||||
def synthesize(body: bytes) -> bytes:
|
||||
@@ -719,7 +719,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
first = {"width": 1366, "height": 768, "url": f"http://localhost:{self.port}/#stream", "wait": 900, "probe": ADD_STATION.replace("@STATION@", station).replace("@NAME@", STATION_NAME), "shot": None, "gesture": True}
|
||||
else:
|
||||
hub = self.httpd.hub
|
||||
hub.settings.put({**hub.settings.get(), "streams": [{"name": STATION_NAME, "url": station, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True}]})
|
||||
hub.settings.put({**hub.settings.get(), "streams": [{"name": STATION_NAME, "url": station, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True, "via": []}]}, hub.settings.get())
|
||||
first = {"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": None, "shot": None}
|
||||
steps = [
|
||||
first,
|
||||
|
||||
@@ -27,7 +27,9 @@ from hub_support import (
|
||||
TREE,
|
||||
FakeScripts,
|
||||
Key,
|
||||
Redirects,
|
||||
key,
|
||||
offline_redirects,
|
||||
server,
|
||||
signing,
|
||||
)
|
||||
@@ -72,6 +74,7 @@ class HubCase(unittest.TestCase):
|
||||
model=server.Model(url="", model="", key=""),
|
||||
engines=self.engines,
|
||||
approvers=self.approvers,
|
||||
redirects=self.redirects(),
|
||||
)
|
||||
self.httpd = httpd
|
||||
self.origin = signing.origin_of(self.port)
|
||||
@@ -82,6 +85,9 @@ class HubCase(unittest.TestCase):
|
||||
self.addCleanup(httpd.shutdown)
|
||||
self.host = f"127.0.0.1:{self.port}"
|
||||
|
||||
def redirects(self) -> Redirects:
|
||||
return offline_redirects()
|
||||
|
||||
def call(self, method: str, path: str, body: bytes | None, headers: dict[str, str]) -> tuple[int, dict, bytes]:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30)
|
||||
try:
|
||||
|
||||
+16
-3
@@ -9,6 +9,8 @@ import importlib.util
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -33,6 +35,7 @@ import hub_api # noqa: F401
|
||||
import passkey
|
||||
import signing
|
||||
import verifier
|
||||
from kvb_orchestrator.redirects import Redirects
|
||||
|
||||
SCORE = "🎯 95 💥 3"
|
||||
GIT_ENV = {
|
||||
@@ -56,10 +59,20 @@ HOOK = {
|
||||
OPTIONS = [{"label": "Merge now", "description": "Gate is green"}, {"label": "Wait", "description": ""}]
|
||||
AMBIENCE = HUB / "templates" / "ambience.js"
|
||||
STREAMS = [
|
||||
{"name": "Morgen", "url": "https://radio.example/morgen", "from": "07:00", "to": "09:00", "days": [0, 1, 2, 3, 4], "on": True},
|
||||
{"name": "Nacht", "url": "http://10.0.0.5:8000/nacht", "from": "22:00", "to": "02:00", "days": [5], "on": True},
|
||||
{"name": "Aus", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False},
|
||||
{"name": "Morgen", "url": "https://radio.example/morgen", "from": "07:00", "to": "09:00", "days": [0, 1, 2, 3, 4], "on": True, "via": []},
|
||||
{"name": "Nacht", "url": "http://10.0.0.5:8000/nacht", "from": "22:00", "to": "02:00", "days": [5], "on": True, "via": []},
|
||||
{"name": "Aus", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False, "via": []},
|
||||
]
|
||||
|
||||
|
||||
def offline(host: str, port: int, timeout: float, public: bool) -> socket.socket:
|
||||
raise ConnectionRefusedError(f"no network in tests: {host}:{port}")
|
||||
|
||||
|
||||
def offline_redirects() -> Redirects:
|
||||
return Redirects(tls=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), dial=offline, timeout=1.0)
|
||||
|
||||
|
||||
VOICE = HUB / "templates" / "voice.js"
|
||||
SPACE = HUB / "templates" / "space.js"
|
||||
NAV = HUB / "templates" / "nav.js"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from hub_support import AMBIENCE
|
||||
from kvb_orchestrator.settings import stream_origin
|
||||
|
||||
DLF = "https://st01.sslstream.dlf.de/dlf/01/high/aac/stream.aac?aggregator=web"
|
||||
OTHER = "https://radio.example/morgen"
|
||||
URLS = [DLF, "https://Radio.Example:443/zweiter?x=1", "http://10.0.0.5:8000/nacht", "https://other.example:8443/", "http://radio.example:80/x"]
|
||||
PROBE = """
|
||||
const A = require(process.argv[1]);
|
||||
const [DLF, OTHER] = JSON.parse(process.argv[2]);
|
||||
const toasts = (events) => {
|
||||
let told = null;
|
||||
const shown = [];
|
||||
for (const [url, kind, cause] of events) {
|
||||
const fresh = A.retold(told, url, kind, cause);
|
||||
if (fresh) {
|
||||
told = fresh;
|
||||
shown.push(`${kind}:${cause}`);
|
||||
}
|
||||
}
|
||||
return shown;
|
||||
};
|
||||
const attempt = [[DLF, "error", "csp"], [DLF, "error", "media"], [DLF, "error", "refused"]];
|
||||
console.log(JSON.stringify({
|
||||
attempt: toasts(attempt),
|
||||
reversed: toasts([...attempt].reverse()),
|
||||
clicks: toasts([1, 2, 3, 4].flatMap(() => attempt)),
|
||||
autoplay: toasts([[DLF, "blocked", "refused"], [DLF, "blocked", "refused"], ...attempt]),
|
||||
stations: toasts([...attempt, [OTHER, "error", "media"], [OTHER, "error", "csp"]]),
|
||||
origins: JSON.parse(process.argv[3]).map((url) => A.stationOrigins({url, via: []})[0]),
|
||||
redirected: A.stationOrigins({url: DLF, via: ["https://*.rndfnk.com"]}),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
class TestRadioTrouble(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
command = ["node", "-e", PROBE, str(AMBIENCE), json.dumps([DLF, OTHER]), json.dumps(URLS)]
|
||||
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=True)
|
||||
cls.data = json.loads(done.stdout)
|
||||
|
||||
def test_a_blocked_redirect_and_its_media_error_toast_once(self):
|
||||
self.assertEqual(self.data["attempt"], ["error:csp"])
|
||||
|
||||
def test_a_later_cause_replaces_a_lesser_one_but_never_the_other_way(self):
|
||||
self.assertEqual(self.data["reversed"], ["error:refused", "error:media", "error:csp"])
|
||||
|
||||
def test_retries_through_clicks_stack_no_toast(self):
|
||||
self.assertEqual(self.data["clicks"], ["error:csp"])
|
||||
|
||||
def test_each_failure_and_station_toasts_on_its_own(self):
|
||||
self.assertEqual(self.data["autoplay"], ["blocked:refused", "error:csp"])
|
||||
self.assertEqual(self.data["stations"], ["error:csp", "error:media", "error:csp"])
|
||||
|
||||
def test_the_page_computes_the_origins_the_server_puts_into_media_src(self):
|
||||
self.assertEqual(self.data["origins"], [stream_origin(url) for url in URLS])
|
||||
self.assertEqual(self.data["redirected"], ["https://st01.sslstream.dlf.de", "https://*.rndfnk.com"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import key, signing
|
||||
from kvb_orchestrator import settings
|
||||
from kvb_orchestrator.errors import ToolError
|
||||
from kvb_orchestrator.redirects import (
|
||||
DEADLINE_HOPS,
|
||||
HOP_LIMIT,
|
||||
RETRY_SECONDS,
|
||||
Redirects,
|
||||
dial,
|
||||
global_address,
|
||||
media_source,
|
||||
)
|
||||
|
||||
NODES = ("f111", "f121", "d111", "f131")
|
||||
STATION = "https://station.radio.test/dlf/01/high/aac/stream.aac?aggregator=web"
|
||||
NAMES = ("radio.test", "*.radio.test", "cdn.test", "*.cdn.test", "*.mid.test")
|
||||
TIMEOUT = 0.5
|
||||
STREAMED = 3.0
|
||||
MUSIC_ON = {"on": True, "volume": 0.4}
|
||||
|
||||
|
||||
class Cdn(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def answer(self, status: int, location: str | None) -> None:
|
||||
self.send_response(status)
|
||||
if location is not None:
|
||||
self.send_header("Location", location)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def stream(self) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "audio/aac")
|
||||
self.end_headers()
|
||||
deadline = time.monotonic() + STREAMED
|
||||
while time.monotonic() < deadline:
|
||||
self.wfile.write(bytes(4096))
|
||||
time.sleep(0.01)
|
||||
|
||||
def drip(self) -> None:
|
||||
self.wfile.write(b"HTTP/1.1 302 Found\r\n")
|
||||
deadline = time.monotonic() + STREAMED
|
||||
while time.monotonic() < deadline:
|
||||
self.wfile.write(b"X-Drip: 1\r\n")
|
||||
time.sleep(TIMEOUT / 2)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
host = self.headers["Host"].split(":")[0]
|
||||
self.server.asked.append(host)
|
||||
try:
|
||||
self.route(host, len(self.server.asked))
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def route(self, host: str, count: int) -> None:
|
||||
if host in self.server.broken:
|
||||
self.answer(302, None)
|
||||
elif host == "station.radio.test":
|
||||
self.answer(302, f"https://{NODES[count % len(NODES)]}.cdn.test/ard/dlf/01/aac/192/stream.aac?aggregator=web&token=t{count}")
|
||||
elif host == "chain.radio.test":
|
||||
self.answer(301, "https://edge.mid.test/hop")
|
||||
elif host == "edge.mid.test":
|
||||
self.answer(307, "https://f111.cdn.test:8443/ard/live.aac")
|
||||
elif host == "moved.radio.test" and self.path == "/a":
|
||||
self.answer(302, "/b")
|
||||
elif host == "plain.radio.test":
|
||||
self.answer(302, "http://f111.cdn.test/live")
|
||||
elif host == "loop.radio.test":
|
||||
self.answer(302, f"/hop{count}")
|
||||
elif host == "short.radio.test":
|
||||
self.answer(302, "https://cdn.test/live")
|
||||
elif host == "slow.radio.test":
|
||||
time.sleep(TIMEOUT * 3)
|
||||
self.answer(200, None)
|
||||
elif host == "drip.radio.test":
|
||||
self.drip()
|
||||
elif host == "badloc.radio.test":
|
||||
self.answer(302, "https://[x")
|
||||
else:
|
||||
self.stream()
|
||||
|
||||
|
||||
class CdnServer(http.server.ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def handle_error(self, request: object, client_address: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
class FakeCdn:
|
||||
def __init__(self) -> None:
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
key, cert = Path(self.folder.name) / "key.pem", Path(self.folder.name) / "cert.pem"
|
||||
names = ",".join(f"DNS:{name}" for name in NAMES)
|
||||
subprocess.run(
|
||||
["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", str(key), "-out", str(cert), "-days", "1", "-subj", "/CN=radio.test", "-addext", f"subjectAltName={names}"],
|
||||
capture_output=True, timeout=60, check=True,
|
||||
)
|
||||
served = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
served.load_cert_chain(cert, key)
|
||||
self.httpd = CdnServer(("127.0.0.1", 0), Cdn)
|
||||
self.httpd.socket = served.wrap_socket(self.httpd.socket, server_side=True)
|
||||
self.httpd.asked = []
|
||||
self.httpd.broken = set()
|
||||
self.dialed: list[tuple[str, int, bool]] = []
|
||||
self.trusted = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
self.trusted.load_verify_locations(cert)
|
||||
self.thread = threading.Thread(target=self.httpd.serve_forever, args=(0.05,), daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def dial(self, host: str, port: int, timeout: float, public: bool) -> socket.socket:
|
||||
self.dialed.append((host, port, public))
|
||||
return socket.create_connection(("127.0.0.1", self.httpd.server_address[1]), timeout)
|
||||
|
||||
def redirects(self, tls: ssl.SSLContext) -> Redirects:
|
||||
return Redirects(tls=tls, dial=self.dial, timeout=TIMEOUT)
|
||||
|
||||
def close(self) -> None:
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
self.thread.join(10)
|
||||
self.folder.cleanup()
|
||||
|
||||
|
||||
CDN: list[FakeCdn] = []
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
CDN.append(FakeCdn())
|
||||
|
||||
|
||||
def tearDownModule() -> None:
|
||||
CDN.pop().close()
|
||||
|
||||
|
||||
def cdn() -> FakeCdn:
|
||||
return CDN[0]
|
||||
|
||||
|
||||
class TestRedirects(unittest.TestCase):
|
||||
def resolver(self) -> Redirects:
|
||||
return cdn().redirects(cdn().trusted)
|
||||
|
||||
def test_a_rotating_cdn_signs_one_wildcard_and_no_body_is_read(self):
|
||||
before = len(cdn().dialed)
|
||||
began = time.monotonic()
|
||||
seen = [self.resolver().via(STATION) for _ in range(4)]
|
||||
self.assertLess(time.monotonic() - began, STREAMED / 2)
|
||||
self.assertEqual(seen, [["https://*.cdn.test"]] * 4)
|
||||
hops = {self.resolver().chain(STATION)[0].split("/")[2] for _ in range(4)}
|
||||
self.assertGreater(len(hops), 1, hops)
|
||||
self.assertTrue(all(host.endswith(".cdn.test") for host in hops), hops)
|
||||
self.assertEqual({port for _, port, _ in cdn().dialed[before:]}, {443})
|
||||
|
||||
def test_every_hop_of_a_chain_is_signed_in_order_with_its_port(self):
|
||||
self.assertEqual(self.resolver().via("https://chain.radio.test/listen"), ["https://*.mid.test", "https://*.cdn.test:8443"])
|
||||
self.assertIn(("f111.cdn.test", 8443, True), cdn().dialed)
|
||||
|
||||
def test_only_the_signed_station_may_lie_at_a_non_global_address(self):
|
||||
port = cdn().httpd.server_address[1]
|
||||
self.assertEqual([global_address(text) for text in ("8.8.8.8", "64:ff9b::808:808", "64:ff9b::7f00:1", "::127.0.0.1", "::ffff:127.0.0.1")], [True, True, False, False, False])
|
||||
for host in ("127.0.0.1", "2130706433", "::1", "10.0.0.5", "169.254.169.254", "64:ff9b::7f00:1", "::127.0.0.1"):
|
||||
with self.subTest(host=host), self.assertRaisesRegex(ToolError, f"{host} has no public address"):
|
||||
dial(host, port, TIMEOUT, True)
|
||||
dial("127.0.0.1", port, TIMEOUT, False).close()
|
||||
before = len(cdn().dialed)
|
||||
self.resolver().via("https://chain.radio.test/listen")
|
||||
self.assertEqual([public for _, _, public in cdn().dialed[before:]], [False, True, True])
|
||||
|
||||
def test_a_station_answering_byte_by_byte_cannot_hold_a_resolve_past_its_deadline_or_pile_up_threads(self):
|
||||
resolver = self.resolver()
|
||||
url = "https://drip.radio.test/"
|
||||
began = time.monotonic()
|
||||
deadline = TIMEOUT * DEADLINE_HOPS
|
||||
self.assertEqual(resolver.resolve([url]), {url: (None, f"no answer within {deadline:g} s")})
|
||||
self.assertLess(time.monotonic() - began, deadline + 1)
|
||||
self.assertEqual(resolver.resolve([url]), {url: (None, "an earlier save still resolves it")})
|
||||
self.assertEqual(sum(thread.name == f"redirects {url}" for thread in threading.enumerate()), 1)
|
||||
|
||||
def test_a_same_host_redirect_adds_nothing_and_a_two_label_host_stays_exact(self):
|
||||
self.assertEqual(self.resolver().via("https://moved.radio.test/a"), [])
|
||||
self.assertEqual(self.resolver().via("https://short.radio.test/"), ["https://cdn.test"])
|
||||
|
||||
def test_an_unresolvable_chain_names_its_cause(self):
|
||||
resolver = self.resolver()
|
||||
self.assertEqual(resolver.outcome("https://plain.radio.test/"), (None, "redirects to http://f111.cdn.test, which is not https"))
|
||||
before = cdn().httpd.asked.count("loop.radio.test")
|
||||
self.assertEqual(resolver.outcome("https://loop.radio.test/"), (None, f"redirects more than {HOP_LIMIT} times"))
|
||||
self.assertEqual(cdn().httpd.asked.count("loop.radio.test") - before, HOP_LIMIT + 1)
|
||||
via, problem = resolver.outcome("https://slow.radio.test/")
|
||||
self.assertIsNone(via)
|
||||
self.assertIn("timed out", problem)
|
||||
cdn().httpd.broken.add("gone.radio.test")
|
||||
self.addCleanup(cdn().httpd.broken.discard, "gone.radio.test")
|
||||
self.assertEqual(resolver.outcome("https://gone.radio.test/"), (None, "answers 302 without a Location"))
|
||||
via, problem = resolver.outcome("https://other.invalid/")
|
||||
self.assertIsNone(via)
|
||||
self.assertIn("Hostname mismatch", problem)
|
||||
via, problem = cdn().redirects(ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)).outcome(STATION)
|
||||
self.assertIsNone(via)
|
||||
self.assertIn("certificate verify failed", problem)
|
||||
via, problem = resolver.outcome("https://badloc.radio.test/")
|
||||
self.assertIsNone(via)
|
||||
self.assertIn("Invalid IPv6 URL", problem)
|
||||
|
||||
def test_only_https_urls_are_probed(self):
|
||||
before = len(cdn().dialed)
|
||||
self.assertEqual(self.resolver().resolve(["http://10.0.0.5:8000/nacht", STATION]), {"http://10.0.0.5:8000/nacht": ([], ""), STATION: (["https://*.cdn.test"], "")})
|
||||
self.assertEqual(len(cdn().dialed) - before, 2)
|
||||
|
||||
def test_media_sources_widen_one_label_and_pass_the_settings_check(self):
|
||||
for url, origin in (
|
||||
("https://f111.rndfnk.com/ard/dlf/01/aac/192/stream.aac?token=t", "https://*.rndfnk.com"),
|
||||
("https://F121.RNDFNK.com:8443/", "https://*.rndfnk.com:8443"),
|
||||
("https://a.b.c.example:443/x", "https://*.b.c.example"),
|
||||
("https://rndfnk.com/x", "https://rndfnk.com"),
|
||||
("https://203.0.113.5/x", "https://203.0.113.5"),
|
||||
("https://bbc.co.uk/x", "https://bbc.co.uk"),
|
||||
("https://a.bbc.co.uk/x", "https://*.bbc.co.uk"),
|
||||
("https://stream.ndr.de/x", "https://*.ndr.de"),
|
||||
):
|
||||
with self.subTest(url=url):
|
||||
self.assertEqual(media_source(url), origin)
|
||||
self.assertEqual(settings.via_origin(origin, "via"), origin)
|
||||
|
||||
|
||||
class TestRedirectSettings(HubCase):
|
||||
def redirects(self) -> Redirects:
|
||||
return cdn().redirects(cdn().trusted)
|
||||
|
||||
def station(self, url: str, via: list[str]) -> dict:
|
||||
return {"name": "DLF", "url": url, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True, "via": via}
|
||||
|
||||
def payload(self, streams: list[dict]) -> dict:
|
||||
return {**settings.SETTINGS, "streams": streams}
|
||||
|
||||
def media_src(self) -> str:
|
||||
csp = self.get("/")[1]["content-security-policy"]
|
||||
return next(part.strip() for part in csp.split(";") if part.strip().startswith("media-src"))
|
||||
|
||||
def dials(self) -> int:
|
||||
return len(cdn().dialed)
|
||||
|
||||
def prepared(self, streams: list[dict]) -> dict:
|
||||
status, _, body = self.post_json("/api/settings/sign", self.payload(streams), self.guard(self.host))
|
||||
self.assertEqual(status, 200, body)
|
||||
return json.loads(body)
|
||||
|
||||
def saved(self, streams: list[dict], proof: dict | None) -> dict:
|
||||
status, _, body = self.post_json("/api/settings", {**self.payload(streams), "proof": proof}, self.guard(self.host))
|
||||
self.assertEqual(status, 200, body)
|
||||
return json.loads(body)
|
||||
|
||||
def signed_and_saved(self, streams: list[dict]) -> dict:
|
||||
prepared = self.prepared(streams)
|
||||
return self.saved(streams, None if prepared["payload"] is None else self.pin().proof(prepared["payload"], self.origin))
|
||||
|
||||
def music_toggled(self, streams: list[dict]) -> dict:
|
||||
status, _, body = self.post_json("/api/settings", {**self.payload(streams), "music": MUSIC_ON, "proof": None}, self.guard(self.host))
|
||||
self.assertEqual(status, 200, body)
|
||||
return json.loads(body)
|
||||
|
||||
def test_an_agent_cannot_make_the_hub_contact_an_unsigned_origin(self):
|
||||
self.pin()
|
||||
before = self.dials()
|
||||
streams = [self.station("https://attacker.radio.test/exfil?secret=s3cr3t", [])]
|
||||
prepared = self.prepared(streams)
|
||||
self.assertEqual(set(prepared), {"payload", "challenge", "credentials"})
|
||||
self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.stream_item(["https://attacker.radio.test"], [streams[0]["url"]]))])
|
||||
unsigned = self.post_json("/api/settings", {**self.payload(streams), "proof": None}, self.guard(self.host))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
stranger = {**self.payload(streams), "proof": key(-7, "stranger").proof(prepared["payload"], self.origin)}
|
||||
self.assertEqual(self.post_json("/api/settings", stranger, self.guard(self.host))[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.dials(), before)
|
||||
self.assertNotIn("attacker.radio.test", cdn().httpd.asked)
|
||||
self.assertFalse((self.state / "settings.json").exists())
|
||||
|
||||
def test_a_signed_save_resolves_and_stores_the_redirect_target(self):
|
||||
streams = [self.station(STATION, [])]
|
||||
self.assertEqual(self.prepared(streams)["payload"]["items"], [signing.item_hash(signing.stream_item(["https://station.radio.test"], [STATION]))])
|
||||
result = self.signed_and_saved(streams)
|
||||
self.assertEqual(result["problems"], [])
|
||||
self.assertEqual(result["settings"]["streams"][0]["via"], ["https://*.cdn.test"])
|
||||
self.assertEqual(settings.Settings(self.state / "settings.json").get(), result["settings"])
|
||||
self.assertEqual(self.media_src(), "media-src 'self' https://*.cdn.test https://station.radio.test")
|
||||
self.assertEqual(self.snapshot()["media"], ["https://*.cdn.test", "https://station.radio.test"])
|
||||
before = self.dials()
|
||||
self.assertIsNone(self.prepared(result["settings"]["streams"])["payload"])
|
||||
self.assertEqual(self.saved(result["settings"]["streams"], None), result)
|
||||
self.assertEqual(self.dials(), before)
|
||||
|
||||
def test_a_music_toggle_with_a_resolved_station_makes_no_dial_even_in_an_outage(self):
|
||||
saved = self.signed_and_saved([self.station(STATION, [])])["settings"]
|
||||
cdn().httpd.broken.add("station.radio.test")
|
||||
self.addCleanup(cdn().httpd.broken.discard, "station.radio.test")
|
||||
before = self.dials()
|
||||
self.assertEqual(self.music_toggled(saved["streams"]), {"settings": {**saved, "music": MUSIC_ON}, "problems": []})
|
||||
self.assertEqual(self.dials(), before)
|
||||
|
||||
def test_a_page_sent_via_is_overwritten(self):
|
||||
saved = self.signed_and_saved([self.station(STATION, [])])["settings"]
|
||||
result = self.saved([{**saved["streams"][0], "via": ["https://*.evil.test"]}], None)
|
||||
self.assertEqual(result["settings"]["streams"][0]["via"], ["https://*.cdn.test"])
|
||||
self.assertNotIn("evil", self.media_src())
|
||||
|
||||
def test_a_new_url_at_a_signed_origin_takes_the_passkey_before_any_dial(self):
|
||||
saved = self.signed_and_saved([self.station(STATION, [])])["settings"]
|
||||
other = self.station("https://station.radio.test/go", [])
|
||||
streams = [*saved["streams"], other]
|
||||
self.assertEqual(self.prepared(streams)["payload"]["items"], [signing.item_hash(signing.stream_item(["https://station.radio.test"], [other["url"]]))])
|
||||
before = self.dials()
|
||||
unsigned = self.post_json("/api/settings", {**self.payload(streams), "proof": None}, self.guard(self.host))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(self.dials(), before)
|
||||
self.assertEqual(self.httpd.hub.settings.get(), saved)
|
||||
|
||||
def test_a_failed_resolution_stays_unresolved_names_the_station_and_retries_once_due(self):
|
||||
saved = self.signed_and_saved([self.station(STATION, [])])["settings"]
|
||||
cdn().httpd.broken.add("station.radio.test")
|
||||
self.addCleanup(cdn().httpd.broken.discard, "station.radio.test")
|
||||
crafted = ["https://*.evil.test"]
|
||||
other = "https://station.radio.test/other"
|
||||
before = self.dials()
|
||||
result = self.signed_and_saved([{**saved["streams"][0], "via": crafted}, self.station(other, crafted)])
|
||||
self.assertEqual(self.dials() - before, 1)
|
||||
self.assertEqual(result["problems"], ["DLF: answers 302 without a Location"])
|
||||
self.assertEqual([stream["via"] for stream in result["settings"]["streams"]], [["https://*.cdn.test"], None])
|
||||
self.assertEqual(self.media_src(), "media-src 'self' https://*.cdn.test https://station.radio.test")
|
||||
cdn().httpd.broken.discard("station.radio.test")
|
||||
before = self.dials()
|
||||
early = self.music_toggled(result["settings"]["streams"])
|
||||
self.assertEqual((self.dials() - before, early["problems"], early["settings"]["streams"][1]["via"]), (0, [], None))
|
||||
self.httpd.hub.redirects.tried[other] -= RETRY_SECONDS
|
||||
retried = self.music_toggled(early["settings"]["streams"])
|
||||
self.assertEqual([stream["via"] for stream in retried["settings"]["streams"]], [["https://*.cdn.test"], ["https://*.cdn.test"]])
|
||||
self.assertEqual(retried["problems"], [])
|
||||
|
||||
def test_a_save_that_lands_while_another_resolves_makes_that_one_fail(self):
|
||||
saved = self.signed_and_saved([self.station(STATION, [])])["settings"]
|
||||
slow = [*saved["streams"], self.station("https://drip.radio.test/", [])]
|
||||
proof = self.pin().proof(self.prepared(slow)["payload"], self.origin)
|
||||
answers: list[tuple] = []
|
||||
racing = threading.Thread(target=lambda: answers.append(self.post_json("/api/settings", {**self.payload(slow), "proof": proof}, self.guard(self.host))))
|
||||
racing.start()
|
||||
time.sleep(TIMEOUT)
|
||||
self.assertEqual(self.music_toggled(saved["streams"])["settings"]["music"], MUSIC_ON)
|
||||
racing.join(TIMEOUT * DEADLINE_HOPS + 5)
|
||||
self.assertEqual(answers[0][:3:2], (400, b"the settings changed while this save was checked; save again\n"))
|
||||
self.assertEqual([stream["url"] for stream in self.httpd.hub.settings.get()["streams"]], [STATION])
|
||||
|
||||
def test_settings_saved_before_redirect_resolution_are_never_dialed_unsigned_and_heal_when_re_added(self):
|
||||
legacy = {name: value for name, value in self.station(STATION, []).items() if name != "via"}
|
||||
(self.state / "settings.json").write_text(json.dumps(self.payload([legacy])), encoding="utf-8")
|
||||
loaded = settings.Settings(self.state / "settings.json").get()
|
||||
self.assertEqual(loaded["streams"], [{**legacy, "via": []}])
|
||||
self.assertEqual(settings.media_origins(loaded), ["https://station.radio.test"])
|
||||
self.httpd.hub.settings.put(loaded, self.httpd.hub.settings.get())
|
||||
self.assertIsNone(self.prepared([legacy])["payload"])
|
||||
self.assertEqual(self.media_src(), "media-src 'self' https://station.radio.test")
|
||||
before = self.dials()
|
||||
self.assertEqual(self.music_toggled([legacy])["settings"]["streams"][0]["via"], [])
|
||||
self.assertEqual(self.dials(), before)
|
||||
self.assertEqual(self.post_json("/api/settings", {**self.payload([]), "proof": None}, self.guard(self.host))[0], 200)
|
||||
result = self.signed_and_saved([legacy])
|
||||
self.assertGreater(self.dials(), before)
|
||||
self.assertEqual(result["settings"]["streams"][0]["via"], ["https://*.cdn.test"])
|
||||
self.assertEqual(self.media_src(), "media-src 'self' https://*.cdn.test https://station.radio.test")
|
||||
|
||||
def test_via_holds_only_canonical_https_origins(self):
|
||||
for via in (
|
||||
"https://cdn.example",
|
||||
["https://*.com"],
|
||||
["https://*.*.cdn.example"],
|
||||
["http://cdn.example"],
|
||||
["https://cdn.example:443"],
|
||||
["https://cdn.example:70000"],
|
||||
["https://cdn.example/path"],
|
||||
["https://user@cdn.example"],
|
||||
["https://cdn.example 'unsafe-inline'"],
|
||||
["https://cdn.example;script-src"],
|
||||
["https://Cdn.example"],
|
||||
[7],
|
||||
["https://a.example", "https://a.example"],
|
||||
[f"https://n{index}.example" for index in range(settings.VIA_LIMIT + 1)],
|
||||
):
|
||||
with self.subTest(via=via):
|
||||
self.assertEqual(self.post_json("/api/settings/sign", {**settings.SETTINGS, "streams": [self.station(STATION, via)]}, self.guard(self.host))[0], 400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -32,7 +32,7 @@ class TestHubSettings(HubCase):
|
||||
proof = None if request is None else signer.proof(request, self.origin)
|
||||
return self.post_json("/api/settings", {**payload, "proof": proof}, self.guard(self.host))
|
||||
|
||||
def test_a_new_stream_origin_needs_the_passkey(self):
|
||||
def test_a_new_stream_url_needs_the_passkey(self):
|
||||
good = self.guard(self.host)
|
||||
payload = self.settings_payload(STREAMS)
|
||||
self.assertEqual(self.post_json("/api/settings", payload, good)[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
@@ -47,11 +47,13 @@ class TestHubSettings(HubCase):
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://other.example:8443 https://radio.example")
|
||||
moved = self.settings_payload([{**STREAMS[0], "url": "https://radio.example/abend"}, STREAMS[1]])
|
||||
self.assertIsNone(self.prepared(moved)["payload"])
|
||||
status, _, body = self.post_json("/api/settings", moved, good)
|
||||
self.assertEqual(self.post_json("/api/settings", moved, good)[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
status, _, body = self.save(moved, signer)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://radio.example")
|
||||
self.assertEqual(self.post_json("/api/settings", self.settings_payload([]), good)[0], 200)
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": proof}, good)[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
|
||||
self.assertEqual(self.save(moved, signer)[0], 200)
|
||||
evil = self.settings_payload([*moved["streams"], {**STREAMS[0], "url": "https://evil.example/listen"}])
|
||||
self.assertEqual(self.post_json("/api/settings", {**evil, "proof": signer.proof(self.prepared(payload)["payload"], self.origin)}, good)[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", evil, good)[0], 400)
|
||||
@@ -137,15 +139,16 @@ class TestHubSettings(HubCase):
|
||||
self.assertEqual(self.post_json("/api/settings", broken, good)[0], 400)
|
||||
status, _, body = self.save(payload, self.pin())
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(json.loads(body), payload)
|
||||
self.assertEqual(json.loads((self.state / "settings.json").read_text(encoding="utf-8")), payload)
|
||||
self.assertEqual(self.snapshot()["settings"], payload)
|
||||
self.assertEqual(settings.Settings(self.state / "settings.json").get(), payload)
|
||||
unresolved = {**payload, "streams": [{**stream, "via": None if stream["url"].startswith("https:") else []} for stream in payload["streams"]]}
|
||||
self.assertEqual(json.loads(body)["settings"], unresolved)
|
||||
self.assertEqual(json.loads((self.state / "settings.json").read_text(encoding="utf-8")), unresolved)
|
||||
self.assertEqual(self.snapshot()["settings"], unresolved)
|
||||
self.assertEqual(settings.Settings(self.state / "settings.json").get(), unresolved)
|
||||
self.assertEqual(self.get("/api/settings")[0], 405)
|
||||
shuffled = {**payload, "speech": {"on": False, "classes": ["mayday", "broadcast", "security"], "muted": ["s1", "Operator", "name:Docs"]}}
|
||||
status, _, body = self.post_json("/api/settings", shuffled, good)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(json.loads(body)["speech"], {"on": False, "classes": ["broadcast", "mayday", "security"], "muted": ["Operator", "name:Docs", "s1"]})
|
||||
self.assertEqual(json.loads(body)["settings"]["speech"], {"on": False, "classes": ["broadcast", "mayday", "security"], "muted": ["Operator", "name:Docs", "s1"]})
|
||||
|
||||
def test_settings_written_before_the_radio_filter_keep_their_values(self):
|
||||
older = {key: value for key, value in self.settings_payload(STREAMS).items() if key != "speech"}
|
||||
|
||||
Reference in new issue
Block a user