-
Notifications
You must be signed in to change notification settings - Fork 1
Send as form, not just form_encoded #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 12 commits
312cab4
4b2ddac
2b2abf7
9f066f3
7891ec7
8d8c267
0a1fe93
dc85ca5
7c64369
0507e91
d4a4d45
dbedf00
1d724a6
470c012
7d78630
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,9 +5,11 @@ | |
| from abc import ABC | ||
| import asyncio | ||
| from collections.abc import Callable | ||
| import contextlib | ||
| from http.cookies import SimpleCookie | ||
| import json | ||
| import logging | ||
| import time | ||
| from typing import Any, Generic, TypeVar | ||
| from urllib.parse import urlparse | ||
|
|
||
|
|
@@ -67,6 +69,8 @@ def __init__( | |
|
|
||
| self.session = session | ||
|
|
||
| self.api_version: int = 8 | ||
|
|
||
| self._use_json_for_login_post = False | ||
| self._auth_cookie: str | None = None | ||
| self._csrf_id: str | None = None | ||
|
|
@@ -76,7 +80,7 @@ def __init__( | |
| # Mostly 8.x API endpoints, login/status are the same in 6.x | ||
| self._login_urls = { | ||
| "default": f"{self.base_url}/api/auth", | ||
| "v6_alternative": f"{self.base_url}/login.cgi", | ||
| "v6_login": f"{self.base_url}/login.cgi", | ||
| } | ||
| self._status_cgi_url = f"{self.base_url}/status.cgi" | ||
| # Presumed 8.x only endpoints | ||
|
|
@@ -201,9 +205,15 @@ def _get_authenticated_headers( | |
| headers["Content-Type"] = "application/x-www-form-urlencoded" | ||
|
|
||
| if self._csrf_id: # pragma: no cover | ||
| _LOGGER.error("TESTv%s - CSRF ID found %s", self.api_version, self._csrf_id) | ||
| headers["X-CSRF-ID"] = self._csrf_id | ||
|
|
||
| if self._auth_cookie: # pragma: no cover | ||
| _LOGGER.error( | ||
| "TESTv%s - auth_cookie found: AIROS_%s", | ||
| self.api_version, | ||
| self._auth_cookie, | ||
| ) | ||
| headers["Cookie"] = f"AIROS_{self._auth_cookie}" | ||
|
|
||
| return headers | ||
|
|
@@ -215,8 +225,17 @@ def _store_auth_data(self, response: aiohttp.ClientResponse) -> None: | |
| # Parse all Set-Cookie headers to ensure we don't miss AIROS_* cookie | ||
| cookie = SimpleCookie() | ||
| for set_cookie in response.headers.getall("Set-Cookie", []): | ||
| _LOGGER.error( | ||
| "TESTv%s - regular cookie handling: %s", self.api_version, set_cookie | ||
| ) | ||
| cookie.load(set_cookie) | ||
| for key, morsel in cookie.items(): | ||
| _LOGGER.error( | ||
| "TESTv%s - AIROS_cookie handling: %s with %s", | ||
| self.api_version, | ||
| key, | ||
| morsel.value, | ||
| ) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if key.startswith("AIROS_"): | ||
| self._auth_cookie = morsel.key[6:] + "=" + morsel.value | ||
| break | ||
|
|
@@ -227,10 +246,11 @@ async def _request_json( | |
| url: str, | ||
| headers: dict[str, Any] | None = None, | ||
| json_data: dict[str, Any] | None = None, | ||
| form_data: dict[str, Any] | None = None, | ||
| form_data: dict[str, Any] | aiohttp.FormData | None = None, | ||
| authenticated: bool = False, | ||
| ct_json: bool = False, | ||
| ct_form: bool = False, | ||
| allow_redirects: bool = True, | ||
| ) -> dict[str, Any] | Any: | ||
| """Make an authenticated API request and return JSON response.""" | ||
| # Pass the content type flags to the header builder | ||
|
|
@@ -242,27 +262,101 @@ async def _request_json( | |
| if headers: | ||
| request_headers.update(headers) | ||
|
|
||
| # Potential XM fix - not sure, might have been login issue | ||
| if self.api_version == 6 and url.startswith(self._status_cgi_url): | ||
| # Ensure all HAR-matching headers are present | ||
| request_headers["Accept"] = "application/json, text/javascript, */*; q=0.01" | ||
| request_headers["Accept-Encoding"] = "gzip, deflate, br, zstd" | ||
| request_headers["Accept-Language"] = "pl" | ||
| request_headers["Cache-Control"] = "no-cache" | ||
| request_headers["Connection"] = "keep-alive" | ||
| request_headers["Host"] = ( | ||
| urlparse(self.base_url).hostname or "192.168.1.142" | ||
| ) | ||
| request_headers["Pragma"] = "no-cache" | ||
| request_headers["Referer"] = f"{self.base_url}/index.cgi" | ||
| request_headers["Sec-Fetch-Dest"] = "empty" | ||
| request_headers["Sec-Fetch-Mode"] = "cors" | ||
| request_headers["Sec-Fetch-Site"] = "same-origin" | ||
| request_headers["User-Agent"] = ( | ||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" | ||
| ) | ||
| request_headers["X-Requested-With"] = "XMLHttpRequest" | ||
| request_headers["sec-ch-ua"] = ( | ||
| '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"' | ||
| ) | ||
| request_headers["sec-ch-ua-mobile"] = "?0" | ||
| request_headers["sec-ch-ua-platform"] = '"Windows"' | ||
|
Comment on lines
+274
to
+297
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: Hardcoded IP fallback in Host header. Line 274 uses a hardcoded IP Apply this diff: request_headers["Host"] = (
- urlparse(self.base_url).hostname or "192.168.1.142"
+ urlparse(self.base_url).hostname or "device.local"
)🤖 Prompt for AI Agents |
||
| if url.startswith(self._login_urls["v6_login"]): | ||
| request_headers["Referrer"] = f"{self.base_url}/login.cgi" | ||
| request_headers["Origin"] = self.base_url | ||
| request_headers["Accept"] = ( | ||
| "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" | ||
| ) | ||
| request_headers["User-Agent"] = ( | ||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" | ||
| ) | ||
| request_headers["Sec-Fetch-Dest"] = "document" | ||
| request_headers["Sec-Fetch-Mode"] = "navigate" | ||
| request_headers["Sec-Fetch-Site"] = "same-origin" | ||
| request_headers["Sec-Fetch-User"] = "?1" | ||
| request_headers["Cache-Control"] = "no-cache" | ||
| request_headers["Pragma"] = "no-cache" | ||
|
|
||
| try: | ||
| if url not in self._login_urls.values() and not self.connected: | ||
| if ( | ||
| url not in self._login_urls.values() | ||
| and url != f"{self.base_url}/" | ||
| and not self.connected | ||
| ): | ||
| _LOGGER.error("Not connected, login first") | ||
| raise AirOSDeviceConnectionError from None | ||
|
|
||
|
Comment on lines
314
to
322
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker: Auth gate and v6/root handling — wrong conditions, JSON decode, and version flip.
@@
- if (
- url not in self._login_urls.values()
- and url != f"{self.base_url}/"
- and not self.connected
- ):
+ if (
+ authenticated
+ and url not in self._login_urls.values()
+ and url != f"{self.base_url}/"
+ and not self.connected
+ ):
_LOGGER.error("Not connected, login first")
raise AirOSDeviceConnectionError from None
@@
- # v6 responds with a 302 redirect and empty body
- if url != self._login_urls["v6_login"]:
- self.api_version = 6
- response.raise_for_status()
+ # v6 login (302/empty) and root prefetch may not be JSON; don't raise here
+ if url not in {self._login_urls["v6_login"], f"{self.base_url}/"}:
+ response.raise_for_status()
@@
- response_text = await response.text()
- _LOGGER.error("Successfully fetched %s from %s", response_text, url)
+ response_text = await response.text()
+ _LOGGER.debug("Fetched response from %s", url)
@@
- _LOGGER.error("TESTv%s - response: %s", self.api_version, response_text)
- # V6 responds with empty body on login, not JSON
- if url.startswith(self._login_urls["v6_login"]):
- self._store_auth_data(response)
- self.connected = True
- return {}
+ _LOGGER.debug("Response body from %s: %s", url, response_text[:200])
+ # Root cookie prefetch: no JSON expected
+ if method == "GET" and url == f"{self.base_url}/":
+ return {}
+ # v6 login: often empty body/redirect; do not JSON-decode
+ if url == self._login_urls["v6_login"]:
+ return {} if not response_text.strip() else response_textAlso applies to: 308-315, 321-328 🤖 Prompt for AI Agents |
||
| if self.api_version == 6 and url.startswith(self._status_cgi_url): | ||
| _LOGGER.error( | ||
| "TESTv%s - adding timestamp to status url!", self.api_version | ||
| ) | ||
| timestamp = int(time.time() * 1000) | ||
| url = f"{self._status_cgi_url}?_={timestamp}" | ||
|
|
||
| _LOGGER.error("TESTv%s - Trying with URL: %s", self.api_version, url) | ||
| async with self.session.request( | ||
| method, | ||
| url, | ||
| json=json_data, | ||
| data=form_data, | ||
| headers=request_headers, # Pass the constructed headers | ||
| allow_redirects=allow_redirects, | ||
| ) as response: | ||
| response.raise_for_status() | ||
| _LOGGER.error( | ||
| "TESTv%s - Response code: %s", self.api_version, response.status | ||
| ) | ||
| _LOGGER.error( | ||
| "TESTv%s - Response headers: %s", | ||
| self.api_version, | ||
| dict(response.headers), | ||
| ) | ||
|
Comment on lines
+330
to
+346
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use debug level for diagnostic logs. Lines 310, 319-320, and 322-326 log diagnostic information at error level. These are not errors but informational messages for debugging request flow. Apply this diff: - _LOGGER.error("TESTv%s - Trying with URL: %s", self.api_version, url)
+ _LOGGER.debug("Requesting URL: %s (api_version=%s)", url, self.api_version)
async with self.session.request(
...
) as response:
- _LOGGER.error(
- "TESTv%s - Response code: %s", self.api_version, response.status
- )
- _LOGGER.error(
- "TESTv%s - Response headers: %s",
- self.api_version,
- dict(response.headers),
- )
+ _LOGGER.debug("Response status: %s (api_version=%s)", response.status, self.api_version)
+ _LOGGER.debug("Response headers: %s (api_version=%s)", dict(response.headers), self.api_version)🤖 Prompt for AI Agents |
||
|
|
||
| # v6 responds with a 302 redirect and empty body | ||
| if not url.startswith(self._login_urls["v6_login"]): | ||
| self.api_version = 6 | ||
| response.raise_for_status() | ||
|
|
||
| response_text = await response.text() | ||
| _LOGGER.debug("Successfully fetched JSON from %s", url) | ||
| _LOGGER.error("Successfully fetched %s from %s", response_text, url) | ||
|
|
||
| # If this is the login request, we need to store the new auth data | ||
| if url in self._login_urls.values(): | ||
| self._store_auth_data(response) | ||
| self.connected = True | ||
|
|
||
| _LOGGER.error("TESTv%s - response: %s", self.api_version, response_text) | ||
| # V6 responds with empty body on login, not JSON | ||
| if url.startswith(self._login_urls["v6_login"]): | ||
| self._store_auth_data(response) | ||
| self.connected = True | ||
| return {} | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return json.loads(response_text) | ||
| except aiohttp.ClientResponseError as err: | ||
| _LOGGER.error( | ||
|
|
@@ -287,32 +381,106 @@ async def login(self) -> None: | |
| """Login to AirOS device.""" | ||
| payload = {"username": self.username, "password": self.password} | ||
| try: | ||
| _LOGGER.error("TESTv%s - Trying default v8 login URL", self.api_version) | ||
| await self._request_json( | ||
| "POST", self._login_urls["default"], json_data=payload | ||
| ) | ||
| except AirOSUrlNotFoundError: | ||
| pass # Try next URL | ||
| _LOGGER.error( | ||
| "TESTv%s - gives URL not found, trying alternative v6 URL", | ||
| self.api_version, | ||
| ) | ||
| # Try next URL | ||
| except AirOSConnectionSetupError as err: | ||
| _LOGGER.error("TESTv%s - failed to login to v8 URL", self.api_version) | ||
| raise AirOSConnectionSetupError("Failed to login to AirOS device") from err | ||
| else: | ||
| _LOGGER.error("TESTv%s - returning from v8 login", self.api_version) | ||
|
Comment on lines
+429
to
+443
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MINOR: Use debug level for login flow logs. Lines 394, 399-402, 405, and 408 log normal login flow information at error level. These should be debug level. Apply this diff: - _LOGGER.error("TESTv%s - Trying default v8 login URL", self.api_version)
+ _LOGGER.debug("Attempting v8 login (api_version=%s)", self.api_version)
await self._request_json(
"POST", self._login_urls["default"], json_data=payload
)
except AirOSUrlNotFoundError:
- _LOGGER.error(
- "TESTv%s - gives URL not found, trying alternative v6 URL",
- self.api_version,
- )
+ _LOGGER.debug("v8 login not found, attempting v6 login (api_version=%s)", self.api_version)
# Try next URL
except AirOSConnectionSetupError as err:
- _LOGGER.error("TESTv%s - failed to login to v8 URL", self.api_version)
+ _LOGGER.debug("v8 login failed (api_version=%s)", self.api_version)
raise AirOSConnectionSetupError("Failed to login to AirOS device") from err
else:
- _LOGGER.error("TESTv%s - returning from v8 login", self.api_version)
+ _LOGGER.debug("v8 login succeeded (api_version=%s)", self.api_version)
return🤖 Prompt for AI Agents |
||
| return | ||
|
|
||
| try: # Alternative URL | ||
| # Start of v6, go for cookies | ||
| _LOGGER.error( | ||
| "TESTv%s - Trying to get /index.cgi first for cookies", self.api_version | ||
| ) | ||
| with contextlib.suppress(Exception): | ||
| cookieresponse = await self._request_json( | ||
| "GET", | ||
| f"{self.base_url}/index.cgi", | ||
| authenticated=True, | ||
| headers={ | ||
| "Referer": f"{self.base_url}/login.cgi", | ||
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", | ||
| }, | ||
| ) | ||
| _LOGGER.error( | ||
| "TESTv%s - Cookie response: %s", self.api_version, cookieresponse | ||
| ) | ||
|
|
||
| v6_simple_multipart_form_data = aiohttp.FormData() | ||
| v6_simple_multipart_form_data.add_field("uri", "/index.cgi") | ||
| v6_simple_multipart_form_data.add_field("username", self.username) | ||
| v6_simple_multipart_form_data.add_field("password", self.password) | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| login_headers = { | ||
| "Referer": self._login_urls["v6_login"], | ||
| } | ||
|
|
||
| _LOGGER.error("TESTv%s - start v6 attempts", self.api_version) | ||
| # --- ATTEMPT B: Simple Payload (multipart/form-data) --- | ||
| try: | ||
| _LOGGER.error( | ||
| "TESTv%s - Trying V6 POST to %s with SIMPLE multipart/form-data", | ||
| self.api_version, | ||
| self._login_urls["v6_login"], | ||
| ) | ||
| await self._request_json( | ||
| "POST", | ||
| self._login_urls["v6_alternative"], | ||
| form_data=payload, | ||
| ct_form=True, | ||
| self._login_urls["v6_login"], | ||
| headers=login_headers, | ||
| form_data=v6_simple_multipart_form_data, | ||
| authenticated=True, | ||
| allow_redirects=True, | ||
| ) | ||
| except AirOSConnectionSetupError as err: | ||
| raise AirOSConnectionSetupError( | ||
| "Failed to login to default and alternate AirOS device urls" | ||
| ) from err | ||
| except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: | ||
| _LOGGER.error( | ||
| "TESTv%s - V6 simple multipart failed (%s) on %s. Error: %s", | ||
| self.api_version, | ||
| type(err).__name__, | ||
| self._login_urls["v6_login"], | ||
| err, | ||
| ) | ||
|
Comment on lines
491
to
516
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use debug level for v6 login attempt logs. Lines 408, 411-415, and 425-431 log normal login flow information at error level. These should be debug level. Apply this diff: - _LOGGER.error("TESTv%s - start v6 attempts", self.api_version)
+ _LOGGER.debug("Starting v6 login attempts (api_version=%s)", self.api_version)
# --- ATTEMPT B: Simple Payload (multipart/form-data) ---
try:
- _LOGGER.error(
- "TESTv%s - Trying V6 POST to %s with SIMPLE multipart/form-data",
- self.api_version,
- self._login_urls["v6_login"],
- )
+ _LOGGER.debug("Attempting v6 simple multipart login (api_version=%s)", self.api_version)
await self._request_json(
...
)
except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err:
- _LOGGER.error(
- "TESTv%s - V6 simple multipart failed (%s) on %s. Error: %s",
- self.api_version,
- type(err).__name__,
- self._login_urls["v6_login"],
- err,
- )
+ _LOGGER.debug(
+ "v6 simple multipart failed (%s): %s (api_version=%s)",
+ type(err).__name__,
+ err,
+ self.api_version,
+ )🤖 Prompt for AI Agents |
||
| except AirOSConnectionAuthenticationError: | ||
| _LOGGER.error( | ||
| "TESTv%s - autherror during extended multipart", self.api_version | ||
| ) | ||
| raise | ||
|
Comment on lines
+517
to
+521
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Message mismatch: says “extended multipart” inside simple attempt. Adjust text to match the attempt type. - _LOGGER.error(
- "TESTv%s - autherror during extended multipart", self.api_version
- )
+ _LOGGER.debug("v6 login auth error (simple multipart) (v%s)", self.api_version)🤖 Prompt for AI Agents |
||
| else: | ||
| _LOGGER.error("TESTv%s - returning from simple multipart", self.api_version) | ||
| # Finalize session by visiting /index.cgi | ||
| _LOGGER.error( | ||
| "TESTv%s - Finalizing session with GET to /index.cgi", self.api_version | ||
| ) | ||
| with contextlib.suppress(Exception): | ||
| await self._request_json( | ||
| "GET", | ||
| f"{self.base_url}/index.cgi", | ||
| headers={ | ||
| "Referer": f"{self.base_url}/login.cgi", | ||
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", | ||
| }, | ||
| authenticated=True, | ||
| allow_redirects=True, | ||
| ) | ||
| return # Success | ||
|
Comment on lines
+522
to
+539
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Major: Overly broad exception suppression in finalization. Line 443 uses Apply this diff: else:
- _LOGGER.error("TESTv%s - returning from simple multipart", self.api_version)
+ _LOGGER.debug("v6 simple multipart succeeded (api_version=%s)", self.api_version)
# Finalize session by visiting /index.cgi
- _LOGGER.error(
- "TESTv%s - Finalizing session with GET to /index.cgi", self.api_version
- )
- with contextlib.suppress(Exception):
+ _LOGGER.debug("Finalizing v6 session (api_version=%s)", self.api_version)
+ try:
await self._request_json(
"GET",
f"{self.base_url}/index.cgi",
headers={
"Referer": f"{self.base_url}/login.cgi",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
},
authenticated=True,
allow_redirects=True,
)
+ except (aiohttp.ClientError, TimeoutError, AirOSDeviceConnectionError) as err:
+ _LOGGER.debug("Session finalization failed (non-fatal): %s", err)
return # Success🤖 Prompt for AI Agents |
||
|
|
||
| async def status(self) -> AirOSDataModel: | ||
| """Retrieve status from the device.""" | ||
| status_headers = { | ||
| "Accept": "application/json, text/javascript, */*; q=0.01", | ||
| "X-Requested-With": "XMLHttpRequest", | ||
| } | ||
| response = await self._request_json( | ||
| "GET", self._status_cgi_url, authenticated=True | ||
| "GET", self._status_cgi_url, authenticated=True, headers=status_headers | ||
| ) | ||
|
|
||
| try: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Sensitive auth data logged at error level without redaction.
Lines 208 and 212-216 log CSRF tokens and authentication cookies at error level with full values exposed. This violates security best practices and can leak credentials in production logs.
Apply this diff to use debug level and redact sensitive values:
🤖 Prompt for AI Agents