klyrek-http 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klyrek_http/__init__.py +8 -0
- klyrek_http/client.py +86 -0
- klyrek_http/py.typed +0 -0
- klyrek_http/rate_limit.py +37 -0
- klyrek_http-0.1.0.dist-info/METADATA +45 -0
- klyrek_http-0.1.0.dist-info/RECORD +7 -0
- klyrek_http-0.1.0.dist-info/WHEEL +4 -0
klyrek_http/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""klyrek-http: scope-checked, rate-limited HTTP client for the Klyrek ecosystem."""
|
|
2
|
+
|
|
3
|
+
from klyrek_http.client import KlyrekHTTPClient
|
|
4
|
+
from klyrek_http.rate_limit import RateLimiter
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = ["KlyrekHTTPClient", "RateLimiter", "__version__"]
|
klyrek_http/client.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""A scope-checked, rate-limited HTTP client shared across the Klyrek ecosystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from klyrek_core.config import KlyrekConfig
|
|
11
|
+
from klyrek_core.logging import get_logger
|
|
12
|
+
from klyrek_core.scope import AuthorizationScope
|
|
13
|
+
from klyrek_http.rate_limit import RateLimiter
|
|
14
|
+
|
|
15
|
+
logger = get_logger("http.client")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KlyrekHTTPClient:
|
|
19
|
+
"""Every Klyrek package that talks to a target should issue requests through this client.
|
|
20
|
+
|
|
21
|
+
It enforces the scan's :class:`AuthorizationScope` and per-host rate limit on every
|
|
22
|
+
request, so no individual package has to remember to do so itself.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
scope: AuthorizationScope,
|
|
28
|
+
config: KlyrekConfig | None = None,
|
|
29
|
+
transport: httpx.BaseTransport | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.scope = scope
|
|
32
|
+
self.config = config or KlyrekConfig()
|
|
33
|
+
min_interval = (
|
|
34
|
+
1.0 / self.config.rate_limit_per_host if self.config.rate_limit_per_host > 0 else 0.0
|
|
35
|
+
)
|
|
36
|
+
self.rate_limiter = RateLimiter(min_interval=min_interval)
|
|
37
|
+
self._client = httpx.Client(
|
|
38
|
+
headers={"User-Agent": self.config.user_agent},
|
|
39
|
+
timeout=self.config.timeout_seconds,
|
|
40
|
+
verify=self.config.verify_tls,
|
|
41
|
+
follow_redirects=True,
|
|
42
|
+
transport=transport,
|
|
43
|
+
event_hooks={"request": [self._enforce_scope_and_rate_limit]},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _enforce_scope_and_rate_limit(self, request: httpx.Request) -> None:
|
|
47
|
+
# Registered as an httpx "request" hook rather than checked once up front, because
|
|
48
|
+
# follow_redirects=True makes httpx dispatch redirect hops as their own requests
|
|
49
|
+
# internally. A check only in request() would see the original URL and miss a
|
|
50
|
+
# redirect to an out-of-scope host.
|
|
51
|
+
url = str(request.url)
|
|
52
|
+
self.scope.check(url)
|
|
53
|
+
self.rate_limiter.wait(request.url.host)
|
|
54
|
+
|
|
55
|
+
def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
56
|
+
logger.debug("%s %s", method, url)
|
|
57
|
+
return self._client.request(method, url, **kwargs)
|
|
58
|
+
|
|
59
|
+
def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
60
|
+
return self.request("GET", url, **kwargs)
|
|
61
|
+
|
|
62
|
+
def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
63
|
+
return self.request("POST", url, **kwargs)
|
|
64
|
+
|
|
65
|
+
def put(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
66
|
+
return self.request("PUT", url, **kwargs)
|
|
67
|
+
|
|
68
|
+
def head(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
69
|
+
return self.request("HEAD", url, **kwargs)
|
|
70
|
+
|
|
71
|
+
def delete(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
72
|
+
return self.request("DELETE", url, **kwargs)
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
self._client.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> KlyrekHTTPClient:
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(
|
|
81
|
+
self,
|
|
82
|
+
exc_type: type[BaseException] | None,
|
|
83
|
+
exc: BaseException | None,
|
|
84
|
+
tb: TracebackType | None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.close()
|
klyrek_http/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Per-host rate limiting so a scan never exceeds its authorized request rate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class RateLimiter:
|
|
12
|
+
"""Enforces a minimum interval between requests to the same host.
|
|
13
|
+
|
|
14
|
+
Hosts are tracked independently: waiting for ``a.com`` never blocks a
|
|
15
|
+
concurrent request to ``b.com``. ``clock``/``sleep`` are injectable so
|
|
16
|
+
tests can run instantly against a fake clock.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
min_interval: float = 0.0
|
|
20
|
+
clock: Callable[[], float] = time.monotonic
|
|
21
|
+
sleep: Callable[[float], None] = time.sleep
|
|
22
|
+
_last_request_at: dict[str, float] = field(default_factory=dict, repr=False)
|
|
23
|
+
|
|
24
|
+
def wait(self, host: str) -> None:
|
|
25
|
+
"""Block, if needed, until ``min_interval`` has passed since the last request to host."""
|
|
26
|
+
if self.min_interval <= 0:
|
|
27
|
+
self._last_request_at[host] = self.clock()
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
now = self.clock()
|
|
31
|
+
last = self._last_request_at.get(host)
|
|
32
|
+
if last is not None:
|
|
33
|
+
remaining = self.min_interval - (now - last)
|
|
34
|
+
if remaining > 0:
|
|
35
|
+
self.sleep(remaining)
|
|
36
|
+
now = self.clock()
|
|
37
|
+
self._last_request_at[host] = now
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klyrek-http
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP client abstraction and request management for the Klyrek ecosystem
|
|
5
|
+
Author: Klyrek Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: appsec,http,pentesting,reconnaissance,security
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Information Technology
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Requires-Dist: klyrek-core
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# klyrek-http
|
|
23
|
+
|
|
24
|
+
The HTTP layer shared by every Klyrek package that talks to a target: `klyrek-crawler`,
|
|
25
|
+
`klyrek-api`, `klyrek-tech`, `klyrek-auth`, `klyrek-js`, `klyrek-assets`, and `klyrek-headers` all
|
|
26
|
+
issue requests through `KlyrekHTTPClient` rather than calling `httpx`/`requests` directly.
|
|
27
|
+
|
|
28
|
+
Every request goes through two checks before it leaves the process:
|
|
29
|
+
|
|
30
|
+
1. **Scope enforcement** — the URL's host must be declared in the scan's
|
|
31
|
+
[`AuthorizationScope`](../klyrek-core/README.md#scope-enforcement), or the request raises
|
|
32
|
+
`ScopeViolationError` instead of firing.
|
|
33
|
+
2. **Rate limiting** — a per-host minimum interval (`KlyrekConfig.rate_limit_per_host`) keeps a
|
|
34
|
+
scan from hammering a target faster than was authorized.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from klyrek_core.config import KlyrekConfig
|
|
38
|
+
from klyrek_core.scope import AuthorizationScope
|
|
39
|
+
from klyrek_http.client import KlyrekHTTPClient
|
|
40
|
+
|
|
41
|
+
scope = AuthorizationScope(authorized_hosts=["target.com", "*.target.com"])
|
|
42
|
+
|
|
43
|
+
with KlyrekHTTPClient(scope, config=KlyrekConfig(rate_limit_per_host=5)) as client:
|
|
44
|
+
response = client.get("https://target.com/")
|
|
45
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
klyrek_http/__init__.py,sha256=zfdB3REfyozkgkVr4JP8zzrkeAnHJDoeAo8zwGqcyQ0,266
|
|
2
|
+
klyrek_http/client.py,sha256=n45IFg6HGrEphVNJBHQBFjru4V4ghnbcTlfhPYWhI94,3059
|
|
3
|
+
klyrek_http/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
klyrek_http/rate_limit.py,sha256=wQ7Uy5ZpKu8qpN_Fjmu06Qz66ynTYiqVl6WRmyIVN0c,1280
|
|
5
|
+
klyrek_http-0.1.0.dist-info/METADATA,sha256=OOcc-7xwHWFu9VBH-ERSR78n-F6GoPUrc00T3ZhomTY,1828
|
|
6
|
+
klyrek_http-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
klyrek_http-0.1.0.dist-info/RECORD,,
|