Skip to content

Commit 7dcca56

Browse files
authored
fix: correctness and security hardening across the HTTP stack (#6)
PR: #6
1 parent 526ecc7 commit 7dcca56

86 files changed

Lines changed: 5928 additions & 409 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/digest.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@
88
verification are out of scope — matching the Java v1 cut.
99
1010
A single handler instance is intended to be reused across requests so the
11-
per-client nonce counter (``nc``) advances monotonically. The counter is
12-
guarded by a ``threading.Lock`` since CPython integer increment is not
13-
atomic with respect to other threads' reads of the same variable.
11+
per-nonce request counter (``nc``) advances correctly. RFC 7616 §3.4 defines
12+
``nc`` as the count of requests the client has sent with the *current* server
13+
nonce, so the counter restarts at ``00000001`` for each fresh nonce and
14+
increments only while a nonce is reused. The per-nonce counts are tracked in a
15+
bounded mapping guarded by a ``threading.Lock`` since CPython dict mutation and
16+
integer increment are not atomic with respect to other threads.
1417
"""
1518

1619
from __future__ import annotations
1720

1821
import hashlib
1922
import secrets
2023
import threading
24+
from collections import OrderedDict
2125
from collections.abc import Callable
2226
from dataclasses import dataclass
2327
from typing import Final
@@ -58,6 +62,13 @@ class _ResolvedChallenge:
5862
"SHA-256-SESS": hashlib.sha256,
5963
}
6064

65+
# Cap on how many distinct server nonces retain a request count. A long-lived
66+
# handler hitting many rotating nonces would otherwise grow the map without
67+
# bound; the oldest entry is evicted past this size. The limit is generous —
68+
# servers rotate nonces rarely — so eviction effectively never affects an
69+
# active nonce.
70+
_MAX_TRACKED_NONCES: Final[int] = 1024
71+
6172

6273
class DigestChallengeHandler:
6374
"""Satisfy a ``Digest`` challenge per RFC 7616.
@@ -79,8 +90,8 @@ class DigestChallengeHandler:
7990

8091
__slots__ = (
8192
"_cnonce_factory",
82-
"_counter",
8393
"_lock",
94+
"_nonce_counts",
8495
"_password",
8596
"_preferred",
8697
"_username",
@@ -102,7 +113,10 @@ def __init__(
102113
self._password = password
103114
self._preferred = preferred_algorithms
104115
self._cnonce_factory = cnonce_factory or (lambda: secrets.token_hex(16))
105-
self._counter = 0
116+
# Maps a server nonce to the count of requests sent with it. Insertion
117+
# order is preserved so the oldest nonce can be evicted once the map
118+
# exceeds ``_MAX_TRACKED_NONCES``.
119+
self._nonce_counts: OrderedDict[str, int] = OrderedDict()
106120
self._lock = threading.Lock()
107121

108122
def can_handle(self, challenges: list[AuthenticateChallenge]) -> bool:
@@ -122,7 +136,7 @@ def handle(
122136
resolved = self._resolve(selected)
123137
if resolved is None:
124138
return None
125-
nc = self._next_nc()
139+
nc = self._next_nc(resolved.nonce)
126140
cnonce = self._cnonce_factory()
127141
uri = _request_uri(url)
128142
response = self._compute_response(
@@ -223,13 +237,33 @@ def _select(self, challenges: list[AuthenticateChallenge]) -> AuthenticateChalle
223237
return challenge
224238
return None
225239

226-
def _next_nc(self) -> str:
240+
def _next_nc(self, nonce: str) -> str:
241+
"""Return the next ``nc`` value for ``nonce`` as 8 lowercase hex digits.
242+
243+
RFC 7616 §3.4 defines ``nc`` as the count of requests sent with the
244+
current server nonce, so the count starts at ``00000001`` for each
245+
fresh nonce and increments on every reuse. A bounded, insertion-ordered
246+
map tracks the per-nonce counts; the oldest nonce is evicted once the
247+
map exceeds ``_MAX_TRACKED_NONCES`` to cap memory on a long-lived
248+
handler that sees many rotating nonces.
249+
250+
Args:
251+
nonce: The server nonce the request is being signed against.
252+
253+
Returns:
254+
The request count for ``nonce`` formatted as 8 lowercase hex
255+
digits, e.g. ``"00000001"`` for the first request.
256+
"""
227257
with self._lock:
228258
# Clamp to 32 bits — ``nc`` is rendered as 8 hex digits per
229259
# RFC 7616, and wrapping after 2**32-1 is acceptable since the
230260
# server hashes the value (no monotonic check).
231-
self._counter = (self._counter + 1) & 0xFFFFFFFF
232-
value = self._counter
261+
value = (self._nonce_counts.get(nonce, 0) + 1) & 0xFFFFFFFF
262+
self._nonce_counts[nonce] = value
263+
# Refresh recency so an actively reused nonce is never evicted.
264+
self._nonce_counts.move_to_end(nonce)
265+
if len(self._nonce_counts) > _MAX_TRACKED_NONCES:
266+
self._nonce_counts.popitem(last=False)
233267
return f"{value:08x}"
234268

235269
def _credentials_encodable(self, charset: str) -> bool:

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/auth/policies.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ class KeyCredentialPolicy(Policy):
3939
SansIO-shaped (no chain wrapping needed) but implemented as a ``Policy``
4040
so it integrates uniformly with the rest of the pipeline.
4141
42+
The credential header is stamped only while the request stays on the
43+
origin recorded on the first pass through the policy. When a downstream
44+
redirect reissues the request against a different origin (scheme, host,
45+
or effective port), the credential is withheld so it never reaches a
46+
foreign host.
47+
4248
Attributes:
4349
header_name: Header to write.
4450
prefix: Optional prefix (with trailing space) for the header value.
@@ -63,12 +69,21 @@ def __init__(
6369
self.prefix = f"{prefix} " if prefix else ""
6470

6571
def send(self, request: Request, ctx: PipelineContext) -> Response:
72+
if _crosses_recorded_origin(request, ctx):
73+
return self.next.send(request, ctx)
6674
value = f"{self.prefix}{self._credential.key}"
6775
return self.next.send(request.with_header(self.header_name, value), ctx)
6876

6977

7078
class BasicAuthPolicy(Policy):
71-
"""Stamp ``Authorization: Basic <base64>`` from a ``BasicAuthCredential``."""
79+
"""Stamp ``Authorization: Basic <base64>`` from a ``BasicAuthCredential``.
80+
81+
The credential is stamped only while the request stays on the origin
82+
recorded on the first pass through the policy. When a downstream redirect
83+
reissues the request against a different origin (scheme, host, or
84+
effective port), the credential is withheld so it never reaches a foreign
85+
host.
86+
"""
7287

7388
STAGE = Stage.AUTH
7489
__slots__ = ("_credential",)
@@ -79,6 +94,8 @@ def __init__(self, credential: BasicAuthCredential) -> None:
7994
self._credential = credential
8095

8196
def send(self, request: Request, ctx: PipelineContext) -> Response:
97+
if _crosses_recorded_origin(request, ctx):
98+
return self.next.send(request, ctx)
8299
value = f"Basic {self._credential.encoded}"
83100
return self.next.send(request.with_header("Authorization", value), ctx)
84101

@@ -91,6 +108,13 @@ class BearerTokenPolicy(Policy):
91108
returns True, or after a 401 response with ``WWW-Authenticate``. Enforces
92109
HTTPS unless ``enforce_https=False`` is passed in ``ctx.options``.
93110
111+
The token is acquired and stamped only while the request stays on the
112+
origin recorded on the first pass. When a downstream redirect reissues the
113+
request against a different origin (scheme, host, or effective port), the
114+
policy forwards the request unchanged — it does not acquire, refresh, or
115+
stamp a token — so the bearer token never reaches a foreign host. The
116+
HTTPS-enforcement check applies only on that same-origin stamping path.
117+
94118
Concurrent refreshes are serialized via a ``threading.Lock`` using a
95119
double-checked pattern so the credential's ``get_token_info`` is invoked
96120
at most once per refresh window even under heavy concurrent send pressure.
@@ -226,6 +250,11 @@ def _authorize(
226250
*,
227251
force_refresh: bool = False,
228252
) -> Request:
253+
# A redirect that crossed origin must not receive the bearer token:
254+
# forward the request unchanged without acquiring or refreshing one,
255+
# and skip the HTTPS enforcement that only governs the stamping path.
256+
if _crosses_recorded_origin(request, ctx):
257+
return request
229258
if ctx.options.get("enforce_https", True) and not _is_https(request.url):
230259
raise ServiceRequestError(
231260
"Bearer token authentication is not permitted for non-HTTPS URLs."
@@ -257,6 +286,13 @@ class AsyncBearerTokenPolicy(AsyncPolicy):
257286
the returned ``(name, value)`` pair is stamped on the retried request.
258287
A 401 invalidates the cached origin token; a 407 leaves it alone because
259288
the proxy, not the origin, rejected the request.
289+
290+
The token is acquired and stamped only while the request stays on the
291+
origin recorded on the first pass. When a downstream redirect reissues the
292+
request against a different origin (scheme, host, or effective port), the
293+
policy forwards the request unchanged — it does not acquire, refresh, or
294+
stamp a token — so the bearer token never reaches a foreign host. The
295+
HTTPS-enforcement check applies only on that same-origin stamping path.
260296
"""
261297

262298
STAGE = Stage.AUTH
@@ -381,6 +417,11 @@ async def _authorize(
381417
*,
382418
force_refresh: bool = False,
383419
) -> Request:
420+
# A redirect that crossed origin must not receive the bearer token:
421+
# forward the request unchanged without acquiring or refreshing one,
422+
# and skip the HTTPS enforcement that only governs the stamping path.
423+
if _crosses_recorded_origin(request, ctx):
424+
return request
384425
if ctx.options.get("enforce_https", True) and not _is_https(request.url):
385426
raise ServiceRequestError(
386427
"Bearer token authentication is not permitted for non-HTTPS URLs."
@@ -398,6 +439,51 @@ async def _authorize(
398439
return request.with_header("Authorization", f"{token.token_type} {token.token}")
399440

400441

442+
_DEFAULT_PORTS: dict[str, int] = {"https": 443, "http": 80}
443+
_AUTH_ORIGIN_KEY: str = "_auth_origin"
444+
445+
446+
def _origin(url: Url) -> tuple[str, str, int | None]:
447+
"""Return the ``(scheme, host, port)`` origin tuple for ``url``.
448+
449+
The scheme and host are lower-cased and the port is resolved to its
450+
scheme default (443 for https, 80 for http) when not explicit, so two
451+
URLs that differ only in an implied/explicit default port compare equal.
452+
453+
Args:
454+
url: The URL to derive an origin from.
455+
456+
Returns:
457+
A ``(scheme, host, effective_port)`` tuple suitable for equality
458+
comparison.
459+
"""
460+
scheme = url.scheme.lower()
461+
port = url.port if url.port is not None else _DEFAULT_PORTS.get(scheme)
462+
return scheme, url.host.lower(), port
463+
464+
465+
def _crosses_recorded_origin(request: Request, ctx: PipelineContext) -> bool:
466+
"""Report whether ``request`` left the origin recorded for this operation.
467+
468+
On the first pass through an auth policy the request's origin is stored in
469+
``ctx.data`` (which is per-operation), so a later redirect reissue can be
470+
compared against it. When the current origin differs, the credential must
471+
not be stamped — this is what stops a redirect to a foreign host from
472+
receiving the caller's credentials.
473+
474+
Args:
475+
request: The request the auth policy is about to forward.
476+
ctx: The per-operation pipeline context.
477+
478+
Returns:
479+
``True`` when the request's origin differs from the one recorded on
480+
the first pass; ``False`` on the first pass or a same-origin reissue.
481+
"""
482+
current = _origin(request.url)
483+
recorded: tuple[str, str, int | None] = ctx.data.setdefault(_AUTH_ORIGIN_KEY, current)
484+
return recorded != current
485+
486+
401487
def _is_https(url: Url) -> bool:
402488
"""Return True if ``url``'s scheme is ``https`` (case-insensitive)."""
403489
return url.scheme.lower() == "https"

packages/dexpace-sdk-core/src/dexpace/sdk/core/http/common/media_type.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,47 @@
1717
_TOKEN_SEPARATORS = frozenset('()<>@,;:\\"/[]?={} \t')
1818

1919

20+
def _split_params(value: str) -> list[str]:
21+
"""Split a media-type wire string on ``;`` outside quoted-strings.
22+
23+
A naive ``value.split(";")`` mis-parses a quoted parameter value that
24+
legitimately contains a semicolon (legal inside an RFC 7230 §3.2.6
25+
quoted-string, e.g. an exotic multipart boundary). This walks the string
26+
with a small state machine that treats ``;`` as a separator only when it
27+
is not enclosed in double quotes, honouring the ``\\X`` quoted-pair escape
28+
so an escaped quote does not toggle the quoted state.
29+
30+
Args:
31+
value: The full wire-form media-type string.
32+
33+
Returns:
34+
The segments split on top-level ``;`` separators, in order. Surrounding
35+
whitespace is not stripped here — callers strip per segment.
36+
"""
37+
segments: list[str] = []
38+
current: list[str] = []
39+
in_quotes = False
40+
i = 0
41+
while i < len(value):
42+
ch = value[i]
43+
if in_quotes and ch == "\\" and i + 1 < len(value):
44+
current.append(ch)
45+
current.append(value[i + 1])
46+
i += 2
47+
continue
48+
if ch == '"':
49+
in_quotes = not in_quotes
50+
elif ch == ";" and not in_quotes:
51+
segments.append("".join(current))
52+
current = []
53+
i += 1
54+
continue
55+
current.append(ch)
56+
i += 1
57+
segments.append("".join(current))
58+
return segments
59+
60+
2061
def _unquote(s: str) -> str:
2162
"""Decode a quoted-string parameter value per RFC 7230 §3.2.6.
2263
@@ -146,7 +187,7 @@ def parse(cls, value: str) -> Self:
146187
"""
147188
if not value or not value.strip():
148189
raise ValueError("media type must not be blank")
149-
segments = [segment.strip() for segment in value.split(";")]
190+
segments = [segment.strip() for segment in _split_params(value)]
150191
mime = segments[0]
151192
slash = mime.find("/")
152193
if slash <= 0 or slash == len(mime) - 1:

0 commit comments

Comments
 (0)