Files
skills/tests/test_hub_redirects.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 ba97f7cc2e 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>
2026-11-08 10:23:32 +00:00

408 lines
20 KiB
Python

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()