Skip to content

Commit e03d53d

Browse files
committed
fix(url): expose response header names in lower case
HTTP header field names are case-insensitive (RFC 9110, section 5.1), but since the switch to httpx the response_header keys arrived lower-cased while consumers still looked them up in their original casing. veeam.py (X-RestSvcSessionId) and huawei.py (Set-Cookie) therefore failed to find their session token / cookie, surfacing as a false "unauthorized". Canonicalize response header names to lower case in url.py and update the consumers to read the canonical key. Tests mirror the real lower-cased transport behaviour.
1 parent 2403ad3 commit e03d53d

7 files changed

Lines changed: 42 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
### Fixed
1818

19+
* huawei.py: the session cookie is read regardless of how the storage system cases the response header, preventing authentication from failing on a case-sensitive header lookup
1920
* redfish.py: Sensors that report an empty min/max range (identical min and max, used by some firmware as a "no limit" placeholder) no longer raise false warnings ([#1211](https://github.com/Linuxfabrik/monitoring-plugins/issues/1211))
2021
* url.py: a caller-supplied `Content-Length` is ignored and recomputed from the request body
21-
* veeam.py: authentication against the Veeam Enterprise Manager API works again
22+
* url.py: response header names are exposed in lower case, so callers read them reliably no matter how the server cased them
23+
* veeam.py: authentication against the Veeam Enterprise Manager API no longer fails with a `415 Unsupported Media Type` error or a false "unauthorized" result
2224
* Installing the library from source (for example `pip install --editable .`) no longer hangs, which also unblocks the API documentation build
2325
* Resolve the remaining ruff lint violations across the library, including a few robustness fixes: a bare `except` in disk.py now catches only `OSError`, mutable default arguments in url.py and rocket.py are no longer shared between calls, and uptimerobot.py uses `isinstance()` instead of a `type()` comparison ([#118](https://github.com/Linuxfabrik/lib/issues/118))
2426

huawei.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"""
1414

1515
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
16-
__version__ = '2025042001'
16+
__version__ = '2026060501'
1717

1818
import time as _time
1919

@@ -160,7 +160,8 @@ def get_creds(args):
160160
)
161161

162162
ibasetoken = result.get('response_json', {}).get('data', {}).get('iBaseToken')
163-
cookie = result.get('response_header', {}).get('Set-Cookie')
163+
# lib.url lower-cases all response header names (RFC 9110, section 5.1).
164+
cookie = result.get('response_header', {}).get('set-cookie')
164165

165166
expire = time.now() + args.CACHE_EXPIRE * 60
166167
cache.set(token_key, ibasetoken, expire)

tests/huawei/unit-test/run

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,11 @@ LOGIN_OK = (
9393
},
9494
'error': {'code': 0, 'description': '0'},
9595
},
96+
# lib.url lower-cases all response header names (RFC 9110, section 5.1),
97+
# so the mock mirrors that canonical form.
9698
'response_header': {
97-
'Set-Cookie': 'session=abc123def456; Path=/deviceManager; HttpOnly',
98-
'Content-Type': 'application/json',
99+
'set-cookie': 'session=abc123def456; Path=/deviceManager; HttpOnly',
100+
'content-type': 'application/json',
99101
},
100102
},
101103
)

tests/url/unit-test/run

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ class TestFetchExtended(unittest.TestCase):
551551
resp = FakeStreamResponse(
552552
body=b'{"k": 1}',
553553
status_code=201,
554-
headers={'content-type': 'application/json', 'x-test': 'yes'},
554+
headers={'Content-Type': 'application/json', 'X-Test': 'yes'},
555555
elapsed_seconds=1.25,
556556
extensions=self._tls_extensions(),
557557
)
@@ -573,7 +573,10 @@ class TestFetchExtended(unittest.TestCase):
573573
)
574574
self.assertEqual(res['response'], '{"k": 1}')
575575
self.assertEqual(res['status_code'], 201)
576+
# response_header keys are canonicalized to lower case, so a header the
577+
# server sent in mixed case ('X-Test') is read back as 'x-test'.
576578
self.assertEqual(res['response_header']['x-test'], 'yes')
579+
self.assertNotIn('X-Test', res['response_header'])
577580
self.assertEqual(res['tls_version'], 'TLSv1.3')
578581
self.assertEqual(res['alpn'], 'h2')
579582
self.assertEqual(res['peer_cert_der'], b'DER')

tests/veeam/unit-test/run

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,17 @@ class FakeArgs:
8080

8181

8282
def make_success(token='8a8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d', extra_headers=None):
83-
"""Build an `extended=True` style success result with a session token."""
84-
headers = {'Content-Type': 'application/json'}
83+
"""Build an `extended=True` style success result with a session token.
84+
85+
`response_header` keys are lower-cased, exactly as `lib.url.fetch_json`
86+
returns them (HTTP header names are case-insensitive, RFC 9110 section 5.1),
87+
so the suite stays faithful to real transport behaviour.
88+
"""
89+
headers = {'content-type': 'application/json'}
8590
if extra_headers:
8691
headers.update(extra_headers)
8792
if token is not None:
88-
headers[TOKEN_HEADER] = token
93+
headers['x-restsvcsessionid'] = token
8994
return {
9095
'response': '{}',
9196
'status_code': 201,
@@ -292,12 +297,14 @@ class TestGetTokenSuccess(unittest.TestCase):
292297
_ok, result = self._run((True, payload))
293298
self.assertEqual(result[TOKEN_HEADER], 'HEADER-TOKEN')
294299

295-
def test_token_lookup_is_case_sensitive(self):
296-
# A lowercase header key is treated as missing -> unauthorized error.
297-
payload = make_success(token=None, extra_headers={'x-restsvcsessionid': 'x'})
298-
ok, msg = self._run((True, payload))
299-
self.assertFalse(ok)
300-
self.assertEqual(msg, 'Something went wrong, maybe user is unauthorized.')
300+
def test_token_lookup_is_case_insensitive(self):
301+
# lib.url lower-cases header names (RFC 9110, section 5.1), so a session
302+
# id delivered under the canonical 'x-restsvcsessionid' key resolves
303+
# regardless of how the server originally cased the header.
304+
payload = make_success(token=None, extra_headers={'x-restsvcsessionid': 'low'})
305+
ok, result = self._run((True, payload))
306+
self.assertTrue(ok)
307+
self.assertEqual(result[TOKEN_HEADER], 'low')
301308

302309
def test_captured_session_payload(self):
303310
# Realistic Veeam EM session response with a token header attached.

url.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""Get for example HTML or JSON from an URL."""
1212

1313
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
14-
__version__ = '2026060301'
14+
__version__ = '2026060501'
1515

1616
import base64
1717
import json
@@ -368,7 +368,7 @@ def fetch(
368368
- On success with `extended=True`, a dict with keys:
369369
- `response`: response body
370370
- `status_code`: int
371-
- `response_header`: dict of response headers
371+
- `response_header`: dict of response headers, keys lower-cased
372372
- `timings`: dict with at least `total` (seconds, float)
373373
- `tls_version`: str like `'TLSv1.3'` or None over plain HTTP
374374
- `alpn`: str like `'h2'` or `'http/1.1'` or None
@@ -501,7 +501,13 @@ def fetch(
501501
response.raise_for_status()
502502
body_bytes = response.read()
503503
status_code = response.status_code
504-
response_headers = dict(response.headers)
504+
# HTTP header field names are case-insensitive (RFC 9110, section 5.1).
505+
# Canonicalize them to lower case so callers can look a header up
506+
# deterministically regardless of how the server cased it. httpx
507+
# already lower-cases, but keep it explicit and backend-independent.
508+
response_headers = {
509+
key.lower(): value for key, value in response.headers.items()
510+
}
505511
elapsed_seconds = response.elapsed.total_seconds()
506512
response_charset = response.charset_encoding
507513
except httpx.HTTPStatusError as e:

veeam.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
Credits go to https://github.com/surfer190/veeam/blob/master/veeam/client.py."""
1313

1414
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
15-
__version__ = '2026060301'
15+
__version__ = '2026060501'
1616

1717
import base64
1818

@@ -80,7 +80,8 @@ def get_token(args):
8080
if not result:
8181
return False, f'There was no result from {uri}.'
8282

83-
token = result.get('response_header', {}).get('X-RestSvcSessionId')
83+
# lib.url lower-cases all response header names (RFC 9110, section 5.1).
84+
token = result.get('response_header', {}).get('x-restsvcsessionid')
8485
if not token:
8586
return False, 'Something went wrong, maybe user is unauthorized.'
8687

0 commit comments

Comments
 (0)