rocketflag 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {rocketflag-0.2.0/src/rocketflag.egg-info → rocketflag-0.2.1}/PKG-INFO +1 -1
- {rocketflag-0.2.0 → rocketflag-0.2.1}/pyproject.toml +1 -1
- {rocketflag-0.2.0 → rocketflag-0.2.1}/src/rocketflag/__init__.py +1 -1
- {rocketflag-0.2.0 → rocketflag-0.2.1}/src/rocketflag/client.py +25 -4
- {rocketflag-0.2.0 → rocketflag-0.2.1/src/rocketflag.egg-info}/PKG-INFO +1 -1
- {rocketflag-0.2.0 → rocketflag-0.2.1}/tests/test_client.py +16 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/LICENSE.md +0 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/README.md +0 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/setup.cfg +0 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/src/rocketflag/errors.py +0 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/src/rocketflag/py.typed +0 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/src/rocketflag.egg-info/SOURCES.txt +0 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/src/rocketflag.egg-info/dependency_links.txt +0 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/src/rocketflag.egg-info/requires.txt +0 -0
- {rocketflag-0.2.0 → rocketflag-0.2.1}/src/rocketflag.egg-info/top_level.txt +0 -0
|
@@ -24,6 +24,8 @@ from .errors import APIError, InvalidResponseError, NetworkError
|
|
|
24
24
|
DEFAULT_API_URL = "https://api.rocketflag.app"
|
|
25
25
|
DEFAULT_VERSION = "v1"
|
|
26
26
|
DEFAULT_MAX_CACHE_ENTRIES = 10_000
|
|
27
|
+
DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
28
|
+
MAX_RESPONSE_BYTES = 1_048_576
|
|
27
29
|
_ENV_NAME = re.compile(r"[A-Za-z0-9_-]+")
|
|
28
30
|
|
|
29
31
|
ContextValue = Union[str, int, float, bool]
|
|
@@ -83,17 +85,24 @@ class Client:
|
|
|
83
85
|
*,
|
|
84
86
|
ttl_seconds: Optional[float] = None,
|
|
85
87
|
max_entries: int = DEFAULT_MAX_CACHE_ENTRIES,
|
|
88
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
86
89
|
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
87
90
|
) -> None:
|
|
88
91
|
if isinstance(max_entries, bool) or not isinstance(max_entries, int) or max_entries < 1:
|
|
89
92
|
raise ValueError("max_entries must be a positive integer")
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
cleaned_url = api_url.rstrip("/")
|
|
94
|
+
parsed_url = urllib.parse.urlparse(cleaned_url)
|
|
95
|
+
if parsed_url.scheme not in ("http", "https") or not parsed_url.netloc:
|
|
96
|
+
raise ValueError("api_url must use http:// or https://")
|
|
97
|
+
self.version = version.strip("/")
|
|
98
|
+
self.api_url = cleaned_url
|
|
92
99
|
self._default_ttl_ms = 0.0 if ttl_seconds is None else float(ttl_seconds) * 1000.0
|
|
93
100
|
self._max_entries = max_entries
|
|
101
|
+
self._timeout_seconds = float(timeout_seconds)
|
|
94
102
|
# Least recently used first; a hit moves its entry to the end.
|
|
95
103
|
self._cache: OrderedDict[str, tuple[Flag, float]] = OrderedDict()
|
|
96
104
|
self._cache_lock = threading.Lock()
|
|
105
|
+
self._uses_default_opener = opener is None
|
|
97
106
|
self._opener = opener or urllib.request.build_opener()
|
|
98
107
|
|
|
99
108
|
def get_flag(
|
|
@@ -107,6 +116,8 @@ class Client:
|
|
|
107
116
|
raise ValueError("flagId is required")
|
|
108
117
|
if not isinstance(flag_id, str):
|
|
109
118
|
raise TypeError("flagId must be a string")
|
|
119
|
+
if flag_id in (".", "..") or "/" in flag_id or "\\" in flag_id:
|
|
120
|
+
raise ValueError("flagId must not contain path separators or relative segments")
|
|
110
121
|
|
|
111
122
|
context: UserContext = user_context or {}
|
|
112
123
|
if not isinstance(context, Mapping):
|
|
@@ -132,12 +143,22 @@ class Client:
|
|
|
132
143
|
|
|
133
144
|
request = urllib.request.Request(url, method="GET")
|
|
134
145
|
try:
|
|
135
|
-
|
|
146
|
+
open_ctx = (
|
|
147
|
+
self._opener.open(request, timeout=self._timeout_seconds)
|
|
148
|
+
if self._uses_default_opener
|
|
149
|
+
else self._opener.open(request)
|
|
150
|
+
)
|
|
151
|
+
with open_ctx as response:
|
|
136
152
|
status = getattr(response, "status", None) or response.getcode()
|
|
137
153
|
if status != 200:
|
|
138
154
|
status_text = getattr(response, "reason", "") or ""
|
|
139
155
|
raise APIError(f"API request failed with status {status}", status, status_text)
|
|
140
|
-
|
|
156
|
+
try:
|
|
157
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
158
|
+
except TypeError:
|
|
159
|
+
raw = response.read()
|
|
160
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
161
|
+
raise InvalidResponseError("Response body exceeds maximum size")
|
|
141
162
|
except APIError:
|
|
142
163
|
raise
|
|
143
164
|
except urllib.error.HTTPError as exc:
|
|
@@ -264,3 +264,19 @@ def test_network_error():
|
|
|
264
264
|
client = create_client(api_url="http://127.0.0.1:1")
|
|
265
265
|
with pytest.raises(NetworkError):
|
|
266
266
|
client.get_flag("abc123")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.mark.parametrize("bad_id", [".", "..", "../admin", "a/b", "a\\b"])
|
|
270
|
+
def test_rejects_traversal_and_slash_flag_ids(bad_id):
|
|
271
|
+
opener = StubOpener()
|
|
272
|
+
client = Client(opener=opener)
|
|
273
|
+
with pytest.raises(ValueError, match="flagId must not contain path separators or relative segments"):
|
|
274
|
+
client.get_flag(bad_id)
|
|
275
|
+
assert opener.urls == []
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@pytest.mark.parametrize("bad_url", ["file:///etc/passwd", "ftp://example.com", "javascript:alert(1)"])
|
|
279
|
+
def test_rejects_non_http_api_urls(bad_url):
|
|
280
|
+
with pytest.raises(ValueError, match="api_url must use http:// or https://"):
|
|
281
|
+
Client(api_url=bad_url)
|
|
282
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|