Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/rules/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,18 @@ cd python && "$PY" -m pip install build && "$PY" -m build # release
- The POSIX venv interpreter is `.venv/bin/python` — override it with `KCSDK_PY`.
- Releasing goes `py-v*` tag → PyPI **Trusted Publisher** (OIDC, no stored secret, human approval gate).
- The package `keycloak_sdk` (distribution name `keycloak-sdk`) ships PEP 561 `py.typed`.
- ⚠️ **`py.typed` makes every module's signatures part of the public type surface, `__all__` or not.** Python has no `internal`, so the only structural way to keep a foreign type out of the consumer API is to put the module under `_internal/` — which is what `secrets`, `redirects` and `jwt` do. ⚠️ **Do not hide a foreign type by widening the annotation to `Any`.** Measured on `JwtValidator.validate(token, key_set)`: with the real `KeySet`, passing a wrong type is **2 mypy errors**; with `Any` it is **0**. That trades a working check for a cosmetic one, and `strict` does not warn about it, so the loss is invisible.
- ⚠️ **`py.typed` makes every module's signatures part of the public type surface, `__all__` or not.** Python has no `internal`, so the only structural way to keep a foreign type out of the consumer API is to put the module under `_internal/`. ⚠️ **Do not hide a foreign type by widening the annotation to `Any`.** Measured on `JwtValidator.validate(token, key_set)`: with the real `KeySet`, passing a wrong type is **2 mypy errors**; with `Any` it is **0**. `strict` does not warn about it, so the loss is invisible.
- ⚠️ **The version constant lives in the manifest only — never keep a second copy.** `__version__` is derived from `importlib.metadata`. A hardcoded value in `__init__.py` once fell behind `pyproject.toml`, so **the published wheel reported its own version wrongly** — and the smoke test was green throughout, because it compared against that same constant.

- ⚠️ **`pip-audit` exits 1 even when it merely skips an editable install** — feed it `pip freeze --exclude-editable` + `-r` instead.

## Gotchas

- ⚠️ **admin has two sessions, and one of them is created lazily** — `connection._s` (REST) and `connection.keycloak_openid.connection._s` (its own token grant). That doubled structure bears on **both hardening and cleanup**.
- **Redirects**: python-keycloak's sync path does not forward `allow_redirects` (`raw_get`/`raw_post` leak kwargs into the query string), so overriding the session's `resolve_redirects` is the only point that cannot be bypassed. Block the outer session alone and the first admin call's grant hands the POST body — `client_secret` included — straight to the redirect target. ⚠️ **What leaks is not the `Authorization` header.** requests' `rebuild_auth` does strip that header across origins, but **a POST body survives a 307/308**, and the credential is in the body. A defence aimed at headers does not stop this one.
- **Redirects**: python-keycloak's sync path does not forward `allow_redirects` (`raw_get`/`raw_post` leak kwargs into the query string), so overriding the session's `resolve_redirects` is the only point that cannot be bypassed. Block the outer session alone and the first admin call's grant hands the POST body — `client_secret` included — straight to the redirect target. ⚠️ **What leaks is not the `Authorization` header.** requests' `rebuild_auth` does strip that header across origins, but **a POST body survives a 307/308**.
- **Cleanup**: if `aclose()` closes only the outer session, the inner `httpx.AsyncClient` stays open and **one FD leaks per client that uses admin** (EMFILE in a long-lived async service). The auth-only path is clean, so a test that never exercises admin can never reveal it. A `finally` contract closes the outer session even when the nested close fails.
- ⚠️ When building the mocks, `MagicMock(spec=KeycloakAdmin)` makes the nested `aclose` a **synchronous** MagicMock — in production it is a coroutine, so without an explicit `AsyncMock` you are asserting against a shape that never occurs.
- ⚠️ **joserfc's `KeySet.import_key_set` raises the stdlib `binascii.Error` on a malformed JWKS — not even a joserfc type** — so a consumer catching `keycloak_sdk.exceptions` **catches nothing** (a §4 violation). The parsing in `_load_jwks` is not covered by `_wrap`: `_wrap` is for transport errors, and this is a problem with the **content** of the response. Both mirrors convert it to `TokenValidationError`.
- ⚠️ **JWKS bypasses upstream `certs()` — a capped direct GET** (three traps, owned by `_internal/jwks_fetch.py`). Its 3xx/non-2xx is `KeycloakTransportError`, unlike every other lane.
- ⚠️ **JWKS refetching has to be DoS-safe.** A forged signature (`BadSignatureError`) **does not** trigger a refetch; an unresolved key id (`InvalidKeyIdError`/`MissingKeyError`) does, rate-limited by `_jwks_min_refetch` (30 s — `.claude/rules/security.md`), and so does a key set 300 s old (`_internal/jwks_cache.py`).
- ⚠️ **3.14's http.client buffer reads up to 131,072 bytes ahead** (≤ 3.13: 8,192) — byte counts under it must not assume ≤ 8 KiB read-ahead (`test_the_body_cap_wins_over_buffer_read_ahead`).
23 changes: 19 additions & 4 deletions python/src/keycloak_sdk/_internal/wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
변수에 걸고, 그 안에서 만들어진 응답이 그것을 붙든다. 원문 읽기마다: 마감이 지났으면
읽지 않는다 · 읽기 하나를 남은 시간으로 묶는다(소켓 타임아웃을 `min(읽기 타임아웃,
남은 시간)` 으로 두고 끝나면 되돌린다) · 마감 뒤에 끝난 읽기의 바이트는 쓰지 않는다 ·
받은 바이트를 예산에 올린다(읽기 하나는 예산 + 1 바이트를 넘게 청하지 않는다). 그래서
받은 바이트를 예산에 올린다(읽기 하나는 남은 예산 + 1 바이트도, 8 KiB 도 넘게 청하지 않는다
— 아래 ⚠️). 그래서
최악은 마감이다(스레드 깨움만큼 늦을 뿐) — 스레드·타이머를 두지 않는다. TLS 레코드
하나를 한 바이트씩 흘려도 같다: CPython 의 `_ssl` 읽기는 소켓 타임아웃을 호출
**하나의 총량**으로 쓴다(레코드가 찰 때까지의 기다림 전부 — 실측, ruby·JVM 과 다르다).
Expand All @@ -40,6 +41,16 @@
정책이라, 틀만 따로 세어도 같은 바이트를 본문으로 보내면 그만이다(시간은 마감이,
할당은 http.client 의 줄 한도가 묶는다).

⚠️ **예산이 걸린 동안 원문 읽기 하나는 8 KiB(`_READ_AHEAD_BYTES`)까지만 청한다.** 이 자리는
버퍼 아래라 http.client 가 아직 쓰지 않은 바이트 — 버퍼가 미리 받은 몫 — 도 센다. 3.14 의 버퍼는
131,072 바이트(`io.DEFAULT_BUFFER_SIZE` — 3.13 까지 8,192)라 청크 경계의 채우기 하나가 틀 몫보다
많이 미리 받았고, 서버가 앞서 있으면 상한 너머 토큰 응답이 본문 상한보다 이 예산에 먼저 걸려
전송 오류(「Can't connect to server」)로 보고됐다(CI 3.14.9 청크 레인 간헐 실패). 8 KiB 면 미리
받은 몫이 틀 몫 안에 남아, 머리·청크 틀이 나머지 56 KiB 안인 상한 너머 본문은 판과 무관하게
본문 상한이 먼저 판정한다
(`tests/unit/test_token_response_cap.py::test_the_body_cap_wins_over_buffer_read_ahead`). 예산이
풀린 본문(`lift`)의 읽기는 묶지 않는다.

⚠️ HTTPAdapter 가 아닌 어댑터(소비자가 `mount` 한 전송)는 건너뛴다 — 그 소켓은 이
SDK 가 볼 수 없다(`retries.py` 와 같은 규칙). 그때도 본문 고리의 마감(`exchange` 의
`check`)은 남는다. HTTPAdapter 의 자리를 못 찾으면 거부한다(`KeycloakConfigError`).
Expand Down Expand Up @@ -70,6 +81,10 @@
#: 싣는다.
FRAMING_ALLOWANCE_BYTES = 65_536

#: 예산이 걸린 동안 원문 읽기 하나가 청하는 바이트의 상한 — 3.13 까지의 응답 버퍼 크기
#: (`io.DEFAULT_BUFFER_SIZE`)다. 버퍼가 미리 받는 몫을 틀 몫 안에 둔다(머리말 ⚠️).
_READ_AHEAD_BYTES = 8_192

#: 이 모듈이 건 래퍼의 표지 — 같은 자리를 두 번 감싸지 않는다.
_MARK = "__kcsdk_wire__"

Expand Down Expand Up @@ -114,7 +129,7 @@ def left() -> float | None:
return remaining

def room() -> int | None:
return None if cap is None else cap - used + 1
return None if cap is None else min(cap - used + 1, _READ_AHEAD_BYTES)

def charge(received: int) -> None:
nonlocal used
Expand All @@ -139,8 +154,8 @@ def lift() -> None:


def _metered(read: _Read, sock: Any, meter: _Meter) -> _Read:
"""응답 버퍼 아래의 원문 읽기 하나 — 앞뒤로 계량을 보고, 읽기를 남은 시간과 남은
예산으로 묶는다."""
"""응답 버퍼 아래의 원문 읽기 하나 — 앞뒤로 계량을 보고, 읽기를 남은 시간과 `room`(남은
예산 + 1 · 8 KiB)으로 묶는다."""
left, room, charge = meter

def readinto(buffer: Any) -> int | None:
Expand Down
58 changes: 58 additions & 0 deletions python/tests/unit/test_token_response_cap.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from __future__ import annotations

import http.client
import json
import socket
import tracemalloc
Expand Down Expand Up @@ -213,6 +214,63 @@
assert peak < _BOUNDED_PEAK, f"16 MiB body: peak {peak} bytes"


def _reading_far_ahead(monkeypatch: pytest.MonkeyPatch) -> None:
"""3.14 의 응답 버퍼 앞에 한참 앞선 서버 — `makefile()` 이 크기를 받지 않으면 131,072 바이트
버퍼(3.14 의 `io.DEFAULT_BUFFER_SIZE`, 3.13 까지 8,192)이고, 응답의 원문 읽기 하나는 청한 만큼을
다 받는다. 채우기는 클라이언트 응답(http.client)에만 건다 — 서버의 요청 읽기에 걸면 멈춘다."""
real_makefile = socket.socket.makefile

def makefile(
self: socket.socket, mode: str = "r", buffering: int | None = None, **kwargs: Any
) -> Any:
if buffering is None or buffering < 0:
buffering = 131_072
return real_makefile(self, mode, buffering, **kwargs)

real_init = http.client.HTTPResponse.__init__

def init(self: http.client.HTTPResponse, *args: Any, **kwargs: Any) -> None:
real_init(self, *args, **kwargs)
read = self.fp.raw.readinto

def filled(buffer: Any) -> int:
view, got = memoryview(buffer), 0
while got < len(view) and (n := read(view[got:])):
got += n
return got

self.fp.raw.readinto = filled

monkeypatch.setattr(socket.socket, "makefile", makefile)
monkeypatch.setattr(http.client.HTTPResponse, "__init__", init)


@pytest.mark.parametrize("chunked", [False, True], ids=["content-length", "chunked"])
@pytest.mark.parametrize("lane", LANES)
def test_the_body_cap_wins_over_buffer_read_ahead(
lane: str, chunked: bool, token_idp: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
"""상한 너머 본문의 판정은 본문 상한이다 — 응답 버퍼가 얼마나 미리 읽든. 소켓의 바이트
예산(본문 상한 + 틀 몫 65,536 — `_internal/wire.py`)은 버퍼가 미리 받은 바이트도 센다. 3.14 의
버퍼가 청크 경계에서 한 번에 차면 본문이 상한에 닿기 전에 예산이 끊었고, 경계는 그것을
「Can't connect to server」 전송 오류로 냈다(위 16 MiB 시험이 CI 3.14.9 청크 레인에서 간헐
실패).

타이밍에 맡기지 않는다 — 3.14 버퍼에 늘 꽉 차는 읽기(`_reading_far_ahead`)를 두고, 첫 조각을
상한 512 바이트 아래까지 늘려 버퍼가 빈 청크 경계가 상한 바로 아래에 오게 한다. 수정 전에는
청크 다섯 레인이 판과 무관하게 전송 오류였다. 길이 레인은 대조군이다 — 머리 뒤로는 `read1` 이
버퍼를 거치지 않아 미리 읽지 않는다."""
_reading_far_ahead(monkeypatch)
head = _head(lane, "M" * 40)
token_idp.head = head + b" " * (CAP - 512 - len(head)) # 한 조각 — 청크 하나로 나간다
token_idp.size, token_idp.chunked = HUGE, chunked

with _client(token_idp) as kc, pytest.raises(Exception) as excinfo:

Check warning on line 268 in python/tests/unit/test_token_response_cap.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is too broad; use a more specific exception type or check the exception message.

See more on https://sonarcloud.io/project/issues?id=xzawed_KeyCloakSDK&issues=AaGQs6GTwmll2o7CsUi-&open=AaGQs6GTwmll2o7CsUi-&pullRequest=925
_call(lane, kc, "M" * 40)

_assert_refused(lane, token_idp, excinfo.value)


@pytest.mark.parametrize("lane", ["cc", "admin"])
def test_a_gzip_bomb_is_refused_after_inflating_only_up_to_the_cap(
lane: str, token_idp: Any
Expand Down
20 changes: 20 additions & 0 deletions python/tests/unit/test_wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,26 @@ def read(buffer: Any) -> int:
assert seen == [4, 2], "둘째 읽기는 남은 예산 + 1 바이트만 청한다"


def test_a_budgeted_read_asks_for_8_kib_however_large_the_buffer() -> None:
# 3.14 의 응답 버퍼는 원문 읽기 하나로 131,072 바이트를 청한다(`io.DEFAULT_BUFFER_SIZE` —
# 3.13 까지 8,192). 서버가 앞서 있으면 그만큼이 와서 http.client 가 쓰기 전에 예산에 오르고,
# 그 몫이 틀 몫(65,536)보다 크다. 예산이 걸린 동안은 8 KiB 만 청한다 — 풀리면(`lift`) 그대로다.
sock, seen = _Sock(1.0), []

def read(buffer: Any) -> int:
seen.append(len(buffer))
return len(buffer) # 서버가 한참 앞서 있다 — 청한 만큼 다 온다

with exchange(60, 1_048_576 + FRAMING_ALLOWANCE_BYTES) as (_check, lift):
readinto = _metered(sock, read)
assert readinto(bytearray(131_072)) == 8_192
lift()
assert readinto(bytearray(131_072)) == 131_072
with exchange(60, None):
assert _metered(sock, read)(bytearray(131_072)) == 131_072
assert seen == [8_192, 131_072, 131_072]


def test_a_non_blocking_read_that_got_nothing_charges_nothing() -> None:
with exchange(60, 0):
assert _metered(_Sock(1.0), lambda _b: None)(bytearray(8)) is None
Expand Down
Loading