Skip to content

Commit c2be066

Browse files
committed
Triage SonarCloud security hotspots on PR #182
S1313 hardcoded IPs: - test_rest_auth.py: switch test fixture IPs from arbitrary literals (1.1.1.1, 2.2.2.2, …) to RFC 5737 documentation ranges (192.0.2.0/24 = TEST-NET-1) via _TEST_IP_A..F module constants. These are reserved for documentation and never appear on the public internet, which is exactly what the rule is meant to encourage. - lan_discovery.py: NOSONAR on the 8.8.8.8 anycast probe with rationale (UDP no-traffic interface-discovery trick — the literal is the well-known address being probed for; parameterising it would only obscure intent). S5332 cleartext HTTP: - admin_client._http_request: NOSONAR — this is a scheme allow- list check, not URL emission. - rest_server.base_url: NOSONAR with deployment note (loopback bind + operator-managed TLS reverse proxy is the documented shape). - admin_console_tab placeholder text, test_admin_client/_url + validator-empty literals, test_usb_browser_tab fixtures: NOSONAR with reasons (placeholder UI, validator-only literals, loopback test server). Web:S5725 SRI on swagger.html: per-tag NOSONAR with rationale — already handled in the JS-smells commit; included here for completeness. S107 webrtc_host.__init__: NOSONAR with rationale — public constructor; the discrete kwargs are clearer at the call sites (GUI panel + multi_viewer) than a callbacks-bag dataclass would be, and breaking the kwarg names would force every operator's external embedding to change.
1 parent e652904 commit c2be066

8 files changed

Lines changed: 61 additions & 17 deletions

File tree

je_auto_control/gui/admin_console_tab.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ def __init__(self, parent: Optional[QWidget] = None) -> None:
5252
self._client = default_admin_console()
5353
self._label_input = QLineEdit()
5454
self._url_input = QLineEdit()
55-
self._url_input.setPlaceholderText("http://host:9939")
55+
# Placeholder text only — the operator types the real URL.
56+
# Default scheme is http to match the bundled local server;
57+
# production deployments should put TLS in front via a reverse
58+
# proxy and the operator can paste an https://… URL here.
59+
self._url_input.setPlaceholderText("http://host:9939") # NOSONAR python:S5332
5660
self._token_input = QLineEdit()
5761
self._token_input.setEchoMode(QLineEdit.Password)
5862
self._table = QTableWidget(0, 5)

je_auto_control/utils/admin/admin_client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ def _http_request(self, host: AdminHost, path: str, *,
169169
method: str, body: Optional[Dict[str, Any]],
170170
) -> Dict[str, Any]:
171171
url = f"{host.base_url}{path}"
172+
# NOSONAR python:S5332 — this is a scheme allowlist check, not
173+
# a URL emission. Both http:// and https:// must be accepted
174+
# because the operator points the admin console at whatever
175+
# the host is actually listening on.
172176
if not url.startswith(("http://", "https://")):
173177
raise ValueError(f"unsupported URL scheme: {url}")
174178
headers = {"Authorization": f"Bearer {host.token}"}

je_auto_control/utils/remote_desktop/lan_discovery.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ def _local_ip() -> str:
4141
try:
4242
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
4343
try:
44-
sock.connect(("8.8.8.8", 80)) # nosec B113 # reason: UDP no-traffic probe; no actual packet sent
44+
# Connect-to-public-IP trick to discover the local interface
45+
# the kernel would pick for outbound traffic; UDP, so no
46+
# packet is sent and 8.8.8.8 (Google DNS) just stands in for
47+
# "any reachable public IP". NOSONAR python:S1313 because
48+
# the literal IS the well-known anycast probe address —
49+
# parameterising it would obscure intent.
50+
sock.connect(("8.8.8.8", 80)) # nosec B113 # NOSONAR python:S1313
4551
return sock.getsockname()[0]
4652
finally:
4753
sock.close()

je_auto_control/utils/remote_desktop/webrtc_host.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class WebRTCDesktopHost:
5555
"one person controls my machine" workflow and keeps the GUI simple.
5656
"""
5757

58-
def __init__(self, *, token: str,
58+
def __init__(self, *, token: str, # NOSONAR python:S107 # public constructor; callbacks/permissions are kept as discrete kwargs to keep the call site readable at the GUI layer (see gui/remote_desktop/webrtc_panel.py + utils/remote_desktop/multi_viewer.py)
5959
config: Optional[WebRTCConfig] = None,
6060
on_state_change: Optional[StateCallback] = None,
6161
on_authenticated: Optional[Callable[[], None]] = None,

je_auto_control/utils/rest_api/rest_server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,11 @@ def is_running(self) -> bool:
346346

347347
@property
348348
def base_url(self) -> str:
349+
# NOSONAR python:S5332 — the embedded HTTP server binds to
350+
# localhost and is meant to sit behind an operator-managed
351+
# reverse proxy that terminates TLS. Returning http:// here
352+
# reflects what's actually listening; admins compose the
353+
# public https:// URL upstream.
349354
host, port = self._address
350355
return f"http://{host}:{port}"
351356

test/unit_test/headless/test_admin_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def client(tmp_path):
2626

2727

2828
def _url(server):
29+
# NOSONAR python:S5332 — tests run against a stub localhost HTTP
30+
# server fixture; TLS would force every test to mint certs and
31+
# offers no real coverage benefit here.
2932
host, port = server.address
3033
return f"http://{host}:{port}"
3134

@@ -39,6 +42,9 @@ def test_add_host_round_trip(client, two_servers):
3942

4043

4144
def test_add_host_validates_required_fields(client):
45+
# NOSONAR python:S5332 — these literals are placeholder URL strings
46+
# passed to a validator that only checks emptiness; no traffic is
47+
# ever sent to "http://x".
4248
with pytest.raises(ValueError):
4349
client.add_host(label="", base_url="http://x", token="t")
4450
with pytest.raises(ValueError):
Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1-
"""Tests for the REST bearer-token + per-IP rate-limit gate (round 23)."""
1+
"""Tests for the REST bearer-token + per-IP rate-limit gate (round 23).
2+
3+
Test IPs use the RFC 5737 documentation ranges (192.0.2.0/24 = TEST-NET-1,
4+
198.51.100.0/24 = TEST-NET-2, 203.0.113.0/24 = TEST-NET-3) so static
5+
analysis tools that flag hardcoded IPs (Sonar S1313) recognise them as
6+
intentional test fixtures rather than real-world routable addresses.
7+
"""
28
from je_auto_control.utils.rest_api.rest_auth import (
39
RestAuthGate, constant_time_equal, generate_token,
410
)
511

612

13+
_TEST_IP_A = "192.0.2.1"
14+
_TEST_IP_B = "192.0.2.2"
15+
_TEST_IP_C = "192.0.2.3"
16+
_TEST_IP_D = "192.0.2.4"
17+
_TEST_IP_E = "192.0.2.5"
18+
_TEST_IP_F = "192.0.2.6"
19+
20+
721
def test_generate_token_is_url_safe_and_unique():
822
a = generate_token()
923
b = generate_token()
@@ -22,51 +36,51 @@ def test_constant_time_equal_matches():
2236

2337
def test_check_accepts_correct_bearer():
2438
gate = RestAuthGate(expected_token="real")
25-
verdict = gate.check(client_ip="1.1.1.1", header_value="Bearer real")
39+
verdict = gate.check(client_ip=_TEST_IP_A, header_value="Bearer real")
2640
assert verdict == "ok"
2741

2842

2943
def test_check_rejects_wrong_token():
3044
gate = RestAuthGate(expected_token="real")
31-
verdict = gate.check(client_ip="1.1.1.1", header_value="Bearer wrong")
45+
verdict = gate.check(client_ip=_TEST_IP_A, header_value="Bearer wrong")
3246
assert verdict == "unauthorized"
3347

3448

3549
def test_check_rejects_missing_header():
3650
gate = RestAuthGate(expected_token="real")
37-
assert gate.check(client_ip="1.1.1.1", header_value=None) == "unauthorized"
38-
assert gate.check(client_ip="1.1.1.1", header_value="") == "unauthorized"
51+
assert gate.check(client_ip=_TEST_IP_A, header_value=None) == "unauthorized"
52+
assert gate.check(client_ip=_TEST_IP_A, header_value="") == "unauthorized"
3953

4054

4155
def test_check_rejects_non_bearer_scheme():
4256
gate = RestAuthGate(expected_token="real")
43-
verdict = gate.check(client_ip="1.1.1.1", header_value="Basic real")
57+
verdict = gate.check(client_ip=_TEST_IP_A, header_value="Basic real")
4458
assert verdict == "unauthorized"
4559

4660

4761
def test_lockout_after_repeated_failures():
4862
gate = RestAuthGate(expected_token="real")
4963
for _ in range(8):
50-
gate.check(client_ip="2.2.2.2", header_value="Bearer wrong")
51-
verdict = gate.check(client_ip="2.2.2.2", header_value="Bearer wrong")
64+
gate.check(client_ip=_TEST_IP_B, header_value="Bearer wrong")
65+
verdict = gate.check(client_ip=_TEST_IP_B, header_value="Bearer wrong")
5266
assert verdict in ("locked_out", "rate_limited"), verdict
5367

5468

5569
def test_lockout_is_per_ip():
5670
"""A bad client must NOT lock out a different IP."""
5771
gate = RestAuthGate(expected_token="real")
5872
for _ in range(20):
59-
gate.check(client_ip="3.3.3.3", header_value="Bearer wrong")
73+
gate.check(client_ip=_TEST_IP_C, header_value="Bearer wrong")
6074
# different client should still be evaluated normally
61-
verdict = gate.check(client_ip="4.4.4.4", header_value="Bearer real")
75+
verdict = gate.check(client_ip=_TEST_IP_D, header_value="Bearer real")
6276
assert verdict == "ok"
6377

6478

6579
def test_rate_limit_kicks_in():
6680
"""Burst is 30 by default — 50 requests in a row should get rate-limited."""
6781
gate = RestAuthGate(expected_token="real")
6882
verdicts = [
69-
gate.check(client_ip="5.5.5.5", header_value="Bearer real")
83+
gate.check(client_ip=_TEST_IP_E, header_value="Bearer real")
7084
for _ in range(50)
7185
]
7286
assert "rate_limited" in verdicts
@@ -75,10 +89,10 @@ def test_rate_limit_kicks_in():
7589
def test_successful_auth_resets_failure_counter():
7690
gate = RestAuthGate(expected_token="real")
7791
for _ in range(3):
78-
gate.check(client_ip="6.6.6.6", header_value="Bearer wrong")
92+
gate.check(client_ip=_TEST_IP_F, header_value="Bearer wrong")
7993
# Successful login clears the failure window.
80-
assert gate.check(client_ip="6.6.6.6", header_value="Bearer real") == "ok"
94+
assert gate.check(client_ip=_TEST_IP_F, header_value="Bearer real") == "ok"
8195
# Now a few more failures should not lock out immediately.
8296
for _ in range(3):
83-
verdict = gate.check(client_ip="6.6.6.6", header_value="Bearer wrong")
97+
verdict = gate.check(client_ip=_TEST_IP_F, header_value="Bearer wrong")
8498
assert verdict == "unauthorized"

test/unit_test/headless/test_usb_browser_tab.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def rest_server():
2323

2424

2525
def test_fetch_returns_list_against_real_server(rest_server):
26+
# NOSONAR python:S5332 — the rest_server fixture binds to localhost
27+
# without TLS; production deployments terminate TLS at a reverse
28+
# proxy. These test URLs never leave the loopback interface.
2629
host, port = rest_server.address
2730
devices = fetch_remote_devices(
2831
base_url=f"http://{host}:{port}", token=rest_server.token,
@@ -42,6 +45,7 @@ def test_fetch_rejects_missing_url():
4245

4346
def test_fetch_propagates_http_error(rest_server):
4447
"""Wrong token surfaces the 401 as a urllib HTTPError."""
48+
# NOSONAR python:S5332 — see test_fetch_returns_list_against_real_server.
4549
host, port = rest_server.address
4650
with pytest.raises(urllib.error.HTTPError):
4751
fetch_remote_devices(
@@ -59,6 +63,7 @@ def test_fetch_accepts_url_without_scheme(rest_server):
5963

6064

6165
def test_fetch_strips_trailing_slash(rest_server):
66+
# NOSONAR python:S5332 — see test_fetch_returns_list_against_real_server.
6267
host, port = rest_server.address
6368
devices = fetch_remote_devices(
6469
base_url=f"http://{host}:{port}/", token=rest_server.token,

0 commit comments

Comments
 (0)