|
15 | 15 |
|
16 | 16 | from pyisy.connection import ( |
17 | 17 | EMPTY_XML_RESPONSE, |
| 18 | + OP_LEGACY_SERVER_CONNECT, |
18 | 19 | Connection, |
19 | 20 | get_sslcontext, |
20 | 21 | ) |
@@ -62,6 +63,17 @@ def test_get_sslcontext_auto_pins_min_v12_no_max() -> None: |
62 | 63 | assert ctx.check_hostname is False |
63 | 64 |
|
64 | 65 |
|
| 66 | +def test_get_sslcontext_does_not_preset_legacy_renegotiation() -> None: |
| 67 | + """``OP_LEGACY_SERVER_CONNECT`` (the ISY-994 RFC-5746 compat flag) |
| 68 | + must NOT be set by default — modern peers (eisy/Polisy IoX, ISY-994 |
| 69 | + firmware that honors RFC 5746) keep strict TLS. The flag is enabled |
| 70 | + on demand by ``Connection.request()`` only when the peer rejects |
| 71 | + the handshake with ``UNSAFE_LEGACY_RENEGOTIATION_DISABLED``.""" |
| 72 | + ctx = get_sslcontext(use_https=True) |
| 73 | + assert ctx is not None |
| 74 | + assert not (ctx.options & OP_LEGACY_SERVER_CONNECT) |
| 75 | + |
| 76 | + |
65 | 77 | def test_get_sslcontext_verify_ssl_true_flips_cert_verification() -> None: |
66 | 78 | """``verify_ssl=True`` is the opt-in for users who installed a |
67 | 79 | properly-signed cert on their controller. It flips both |
@@ -352,6 +364,79 @@ async def test_request_ssl_error_raises_on_test_connection_path( |
352 | 364 | await conn.request(url, retries=None) |
353 | 365 |
|
354 | 366 |
|
| 367 | +async def test_request_legacy_reneg_failure_enables_compat_and_retries() -> None: |
| 368 | + """First handshake fails with ``UNSAFE_LEGACY_RENEGOTIATION_DISABLED`` |
| 369 | + (signature of ISY-994's pre-RFC-5746 TLS stack against modern OpenSSL). |
| 370 | + ``request()`` must: |
| 371 | +
|
| 372 | + 1. Flip ``OP_LEGACY_SERVER_CONNECT`` on the existing SSL context |
| 373 | + (modern peers — eisy/Polisy IoX, ISY-994 firmware that honors |
| 374 | + RFC 5746 — never reach this branch and stay strict). |
| 375 | + 2. Log a one-time WARNING explaining the degradation. |
| 376 | + 3. Retry the request and surface the second response normally. |
| 377 | + """ |
| 378 | + from unittest.mock import MagicMock |
| 379 | + |
| 380 | + https_conn = Connection(address="h", port=443, username="u", password="p", use_https=True) |
| 381 | + try: |
| 382 | + # Sanity: flag is OFF on a fresh connection (verifies the |
| 383 | + # context-builder default; the request-path retry is what |
| 384 | + # actually flips it). |
| 385 | + assert https_conn.sslcontext is not None |
| 386 | + assert not (https_conn.sslcontext.options & OP_LEGACY_SERVER_CONNECT) |
| 387 | + |
| 388 | + url = https_conn.compile_url(["config"]) |
| 389 | + reneg_err = aiohttp.ClientConnectorSSLError( |
| 390 | + MagicMock(), |
| 391 | + ssl.SSLError( |
| 392 | + 1, |
| 393 | + "[SSL: UNSAFE_LEGACY_RENEGOTIATION_DISABLED] unsafe legacy renegotiation disabled", |
| 394 | + ), |
| 395 | + ) |
| 396 | + with aioresponses() as mocked: |
| 397 | + # First call: handshake refusal. Second call (after the |
| 398 | + # retry flips the flag): success. |
| 399 | + mocked.get(url, exception=reneg_err) |
| 400 | + mocked.get(url, status=200, body="<configuration/>") |
| 401 | + result = await https_conn.request(url) |
| 402 | + |
| 403 | + assert result == "<configuration/>" |
| 404 | + assert https_conn.sslcontext.options & OP_LEGACY_SERVER_CONNECT |
| 405 | + finally: |
| 406 | + await https_conn.close() |
| 407 | + |
| 408 | + |
| 409 | +async def test_request_legacy_reneg_does_not_trigger_for_unrelated_ssl_errors() -> None: |
| 410 | + """Only the specific ``UNSAFE_LEGACY_RENEGOTIATION_DISABLED`` |
| 411 | + failure flips the flag. A generic protocol error |
| 412 | + (``UNSUPPORTED_PROTOCOL``, cert verify failure, etc.) must surface |
| 413 | + as ``ISYConnectionError`` without weakening the SSL context — those |
| 414 | + are real config mismatches the user needs to fix, not ISY-994 |
| 415 | + legacy compat.""" |
| 416 | + from unittest.mock import MagicMock |
| 417 | + |
| 418 | + from pyisy.exceptions import ISYConnectionError |
| 419 | + |
| 420 | + https_conn = Connection(address="h", port=443, username="u", password="p", use_https=True) |
| 421 | + try: |
| 422 | + url = https_conn.compile_url(["config"]) |
| 423 | + proto_err = aiohttp.ClientConnectorSSLError( |
| 424 | + MagicMock(), |
| 425 | + ssl.SSLError(1, "[SSL: UNSUPPORTED_PROTOCOL] unsupported protocol"), |
| 426 | + ) |
| 427 | + with aioresponses() as mocked: |
| 428 | + mocked.get(url, exception=proto_err) |
| 429 | + with pytest.raises(ISYConnectionError, match="SSL/TLS error"): |
| 430 | + await https_conn.request(url) |
| 431 | + |
| 432 | + # Flag must remain OFF — the user's security posture isn't |
| 433 | + # silently weakened on every SSL failure. |
| 434 | + assert https_conn.sslcontext is not None |
| 435 | + assert not (https_conn.sslcontext.options & OP_LEGACY_SERVER_CONNECT) |
| 436 | + finally: |
| 437 | + await https_conn.close() |
| 438 | + |
| 439 | + |
355 | 440 | async def test_request_non_rest_url_does_not_crash(conn: Connection) -> None: |
356 | 441 | """Regression for #488: ``request()`` derives its debug-log endpoint |
357 | 442 | from the URL by splitting on ``"rest"``. ``get_description()`` builds |
|
0 commit comments