diff --git a/tests/test_irc.py b/tests/test_irc.py index 2f88941..9b64793 100644 --- a/tests/test_irc.py +++ b/tests/test_irc.py @@ -150,3 +150,39 @@ def test_no_substring_of_the_line_survives(self): out = _loggable_verb(line) assert payload not in out assert out in allowed + + +class TestTLSContext: + """The Twitch bot sends its OAuth token via PASS, so the TLS floor matters. + + `ssl.create_default_context()` leaves `minimum_version` at the + MINIMUM_SUPPORTED sentinel, which permits TLS 1.0/1.1 wherever the + platform still enables them (CodeQL alert #13). + """ + + def test_context_pins_tls12_floor(self): + import ssl + + from twitch import _make_tls_context + + # Negative control: exhibit a context that FAILS the assertion below, + # so it cannot pass vacuously. Deliberately not asserting anything + # about create_default_context()'s own default — that varies by + # interpreter (3.12 reports the MINIMUM_SUPPORTED sentinel, 3.14 + # already pins TLS 1.2), so a control built on it passes locally and + # fails in CI, which is exactly what happened here. + permissive = ssl.create_default_context() + permissive.minimum_version = ssl.TLSVersion.MINIMUM_SUPPORTED + assert permissive.minimum_version < ssl.TLSVersion.TLSv1_2 + + assert _make_tls_context().minimum_version >= ssl.TLSVersion.TLSv1_2 + + def test_context_still_verifies_certificates(self): + import ssl + + from twitch import _make_tls_context + + ctx = _make_tls_context() + # hardening the floor must not weaken verification + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname is True diff --git a/twitch.py b/twitch.py index 2fa7fcc..63533be 100755 --- a/twitch.py +++ b/twitch.py @@ -116,6 +116,20 @@ def _load_dotenv(dotenv_path: Path) -> None: # ============================================================================= +def _make_tls_context() -> ssl.SSLContext: + """Return a TLS context with a TLS 1.2 floor. + + `ssl.create_default_context()` leaves `minimum_version` at the + MINIMUM_SUPPORTED sentinel, so TLS 1.0/1.1 remain permitted wherever + the platform still enables them. This bot sends its OAuth token in the + IRC PASS command, so the negotiated floor is what protects that token + in transit. + """ + context = ssl.create_default_context() + context.minimum_version = ssl.TLSVersion.TLSv1_2 + return context + + class BotConfig: """ Configuration class holding all bot settings. @@ -235,7 +249,7 @@ def connect(self) -> None: # the PASS command during authentication — travels encrypted rather than # in plaintext. Twitch serves IRC over TLS on port 6697. raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - context = ssl.create_default_context() + context = _make_tls_context() self.socket = context.wrap_socket(raw_sock, server_hostname=self.config.SERVER) self.socket.connect((self.config.SERVER, self.config.PORT))