Skip to content

Commit 448a9c9

Browse files
committed
fix: CM8200 session reuse to prevent brute-force lockout
The CM8200A modem has a brute-force lockout that triggers after a few credential GETs in quick succession. Previously, every poll cycle sent fresh credentials, causing lockout after ~30 minutes (3 cycles at 900s). The driver now: - Probes the status page without credentials first (IP-based session reuse) - Only sends credentials when the session has actually expired - Checks Admin_Login_Lock.txt before credential auth to detect lockout - Falls back gracefully if the lockout check itself fails Reported-by: GitHub user via #168
1 parent ab06691 commit 448a9c9

File tree

2 files changed

+245
-99
lines changed

2 files changed

+245
-99
lines changed

app/drivers/cm8200.py

Lines changed: 75 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -76,22 +76,75 @@ def __init__(self, url: str, user: str, password: str):
7676
self._cookie_header = None
7777

7878
def login(self) -> None:
79-
"""Authenticate via base64 credentials in query string.
80-
81-
The CM8200A auth flow:
82-
1. GET /cmconnectionstatus.html?{base64creds} returns a bare
83-
session token string (~31 alphanumeric chars, not HTML).
84-
2. The modem's Set-Cookie response header is malformed (firmware
85-
bug). The browser JS works around this by writing the literal
86-
Set-Cookie value + token into document.cookie, producing:
87-
Cookie: HttpOnly: true, Secure: true; credential=<token>
88-
3. Subsequent requests must include this Cookie header.
89-
90-
Note: the modem has a 5-minute brute-force lockout with no visible
91-
error. It returns the login page (HTTP 200, ~4170 bytes) instead
92-
of the status page when locked out.
79+
"""Authenticate with the CM8200A, reusing IP-based sessions.
80+
81+
The CM8200A uses IP-based session management. After a successful
82+
credential GET, subsequent bare GETs from the same IP return the
83+
status page without needing credentials again. The modem has a
84+
brute-force lockout (~5 minutes) that triggers after a few
85+
credential GETs in quick succession, so we minimise credential
86+
requests by trying to reuse the existing session first.
87+
88+
Flow:
89+
1. Probe: bare GET /cmconnectionstatus.html (no credentials).
90+
If the modem still recognises our IP, it returns the status
91+
page (~12 kB) and we skip credential auth entirely.
92+
2. If the probe returns the login page (~4 kB), check the
93+
lockout endpoint before sending credentials.
94+
3. Full auth: GET /cmconnectionstatus.html?{base64creds} to
95+
obtain a session token, then GET the status page.
9396
"""
9497
self._status_html = None
98+
99+
# Phase 1: try reusing existing IP-based session
100+
if self._try_session_reuse():
101+
return
102+
103+
# Phase 2: check lockout before sending credentials
104+
self._check_lockout()
105+
106+
# Phase 3: full credential-based auth
107+
self._credential_auth()
108+
109+
def _try_session_reuse(self) -> bool:
110+
"""Attempt to fetch the status page without sending credentials.
111+
112+
Returns True if the session is still valid and _status_html is set.
113+
"""
114+
try:
115+
headers = {"Cookie": self._cookie_header} if self._cookie_header else {}
116+
r = self._session.get(
117+
f"{self._url}/cmconnectionstatus.html",
118+
headers=headers,
119+
timeout=30,
120+
)
121+
r.raise_for_status()
122+
if len(r.text) > 5000 and "downstream bonded" in r.text.lower():
123+
self._status_html = r.text
124+
log.info("CM8200 session reused")
125+
return True
126+
log.debug("CM8200 session expired, re-authenticating")
127+
except requests.RequestException:
128+
log.debug("CM8200 session probe failed, re-authenticating")
129+
return False
130+
131+
def _check_lockout(self) -> None:
132+
"""Check the modem's brute-force lockout status before sending credentials."""
133+
try:
134+
r = self._session.get(
135+
f"{self._url}/Admin_Login_Lock.txt",
136+
timeout=10,
137+
)
138+
if r.text.strip() == "Locked":
139+
raise RuntimeError(
140+
"CM8200 modem is in brute-force lockout. "
141+
"Wait 5 minutes or reboot the modem."
142+
)
143+
except requests.RequestException:
144+
pass # If the check fails, proceed with auth attempt
145+
146+
def _credential_auth(self) -> None:
147+
"""Full credential-based authentication flow."""
95148
creds = base64.b64encode(f"{self._user}:{self._password}".encode()).decode()
96149
for attempt in range(2):
97150
try:
@@ -195,49 +248,20 @@ def _fetch_status_page(self) -> BeautifulSoup:
195248
``get_device_info()`` and ``get_docsis_data()`` share the same
196249
snapshot without triggering a second auth round-trip.
197250
198-
If no cache is available (e.g. session expired mid-poll), falls
199-
back to a full re-auth with the same validation as ``login()``.
251+
If no cache is available (e.g. session expired mid-poll), tries
252+
session reuse first, then falls back to full credential auth.
200253
"""
201254
if self._status_html:
202255
log.debug("CM8200 using cached status HTML (%d bytes)", len(self._status_html))
203256
return BeautifulSoup(self._status_html, "html.parser")
204257

205-
creds = base64.b64encode(f"{self._user}:{self._password}".encode()).decode()
206-
try:
207-
r1 = self._session.get(
208-
f"{self._url}/cmconnectionstatus.html?{creds}",
209-
timeout=30,
210-
)
211-
token = r1.text.strip()
212-
213-
if len(token) > 64 or not token.isalnum():
214-
raise RuntimeError(
215-
"CM8200 re-auth failed: expected session token but received "
216-
f"{len(token)} bytes (wrong credentials or brute-force "
217-
"lockout, wait 5 minutes)"
218-
)
219-
220-
self._cookie_header = f"HttpOnly: true, Secure: true; credential={token}"
221-
222-
r = self._session.get(
223-
f"{self._url}/cmconnectionstatus.html",
224-
headers={"Cookie": self._cookie_header},
225-
timeout=30,
226-
)
227-
r.raise_for_status()
228-
229-
if len(r.text) < 5000 or "downstream bonded" not in r.text.lower():
230-
raise RuntimeError(
231-
"CM8200 re-auth succeeded but status page not returned "
232-
f"({len(r.text)} bytes). Modem may be in brute-force "
233-
"lockout (wait 5 minutes)."
234-
)
235-
except requests.RequestException as e:
236-
raise RuntimeError(f"CM8200 status page retrieval failed: {e}")
258+
# Try session reuse before sending credentials
259+
if self._try_session_reuse():
260+
return BeautifulSoup(self._status_html, "html.parser")
237261

238-
self._status_html = r.text
239-
log.debug("CM8200 status page fetched (%d bytes)", len(r.text))
240-
return BeautifulSoup(r.text, "html.parser")
262+
self._check_lockout()
263+
self._credential_auth()
264+
return BeautifulSoup(self._status_html, "html.parser")
241265

242266
@staticmethod
243267
def _find_channel_tables(soup) -> tuple:

0 commit comments

Comments
 (0)