webget-cli 0.7.1__tar.gz → 0.7.2__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.
- {webget_cli-0.7.1 → webget_cli-0.7.2}/PKG-INFO +1 -1
- {webget_cli-0.7.1 → webget_cli-0.7.2}/pyproject.toml +1 -1
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_ssrf.py +38 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_browser_ssrf.py +56 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/PKG-INFO +1 -1
- {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.py +19 -1
- {webget_cli-0.7.1 → webget_cli-0.7.2}/LICENSE +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/README.md +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/setup.cfg +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_http.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_auth_review.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_cache_review.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_mcp_leak_review.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_mcp_server.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_security_review.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_size_review.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_webget.py +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/SOURCES.txt +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/requires.txt +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/top_level.txt +0 -0
- {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_mcp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
5
|
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webget-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -130,3 +130,41 @@ class TestSSRFGuardLadder:
|
|
|
130
130
|
out = _one(res)
|
|
131
131
|
assert "attempts" in out
|
|
132
132
|
assert out["method"] in ("http", "crawl4ai", "firecrawl", "")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestRequestBodyBytes:
|
|
136
|
+
"""Regression (0.7.1 bug): the browser route guard read request.post_data,
|
|
137
|
+
which decodes UTF-8 and raises UnicodeDecodeError on binary/compressed
|
|
138
|
+
bodies (gzip POSTs, seen on facebook/linkedin). The guard must read the
|
|
139
|
+
UNDECODED bytes (post_data_buffer), and body handling must never be part
|
|
140
|
+
of the SSRF decision."""
|
|
141
|
+
|
|
142
|
+
class _BinaryRequest:
|
|
143
|
+
"""Stub of a Playwright request with a gzip/binary body."""
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def post_data(self):
|
|
147
|
+
# The exact 0.7.1 crash: post_data forces UTF-8 decode.
|
|
148
|
+
raise UnicodeDecodeError("utf-8", b"\x8b", 0, 1, "invalid start byte")
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def post_data_buffer(self):
|
|
152
|
+
return b"\x1f\x8b\x08\x00binary-gzip-body"
|
|
153
|
+
|
|
154
|
+
def test_returns_undecoded_bytes(self):
|
|
155
|
+
assert webget._request_body_bytes(self._BinaryRequest()) == (
|
|
156
|
+
b"\x1f\x8b\x08\x00binary-gzip-body"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def test_never_calls_post_data(self):
|
|
160
|
+
"""If someone reverts to request.post_data, this test must fail."""
|
|
161
|
+
r = self._BinaryRequest()
|
|
162
|
+
assert webget._request_body_bytes(r) is not None # would raise otherwise
|
|
163
|
+
|
|
164
|
+
def test_none_on_any_error(self):
|
|
165
|
+
class _Broken:
|
|
166
|
+
@property
|
|
167
|
+
def post_data_buffer(self):
|
|
168
|
+
raise RuntimeError("transport closed")
|
|
169
|
+
|
|
170
|
+
assert webget._request_body_bytes(_Broken()) is None
|
|
@@ -16,6 +16,7 @@ request at all, not merely that its response was discarded.
|
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
|
+
import gzip
|
|
19
20
|
import sys
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
|
|
@@ -147,3 +148,58 @@ class TestNavigationRedirectSSRF:
|
|
|
147
148
|
assert out["status"] == "error"
|
|
148
149
|
# scrape_many's pre-check blocks before the browser ever runs.
|
|
149
150
|
assert server.hits_for(PRIVATE_PAGE) == 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestBinaryPostGuard:
|
|
154
|
+
"""Regression (0.7.1 bug): the route guard read request.post_data which
|
|
155
|
+
decodes UTF-8 and raises UnicodeDecodeError on gzip/binary POST bodies
|
|
156
|
+
(seen on facebook/linkedin). The handler died BEFORE the private-address
|
|
157
|
+
check, Playwright continued the request natively, and a binary POST to a
|
|
158
|
+
private target BYPASSED the SSRF guard entirely.
|
|
159
|
+
|
|
160
|
+
Fix: guard reads undecoded bytes (post_data_buffer); SSRF decisions are
|
|
161
|
+
URL/IP policy only. These tests prove:
|
|
162
|
+
- binary POST to PUBLIC target: guard alive, exact body replayed
|
|
163
|
+
- binary POST to PRIVATE target: aborted, endpoint NEVER receives it
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
BINARY_BODY = gzip.compress(b"binary payload " * 50)
|
|
167
|
+
|
|
168
|
+
def _driver_url(self, server, target):
|
|
169
|
+
return server.url(f"/binary-post-driver?to={target}")
|
|
170
|
+
|
|
171
|
+
def test_binary_post_to_public_allowed(self, isolated, monkeypatch):
|
|
172
|
+
server = isolated
|
|
173
|
+
driver = "/binary-post-driver?to=/binary-post"
|
|
174
|
+
monkeypatch.setattr(
|
|
175
|
+
webget,
|
|
176
|
+
"_is_private_target",
|
|
177
|
+
_fake_public_policy(server, extra_public=(driver, "/binary-post")),
|
|
178
|
+
)
|
|
179
|
+
server.reset_counters()
|
|
180
|
+
res = asyncio.run(_browser_fetch(server.url(driver)))
|
|
181
|
+
out = res[server.url(driver)]
|
|
182
|
+
assert out["status"] == "success", out
|
|
183
|
+
# The gzip POST went through the guard; the server received the
|
|
184
|
+
# EXACT bytes (guard replayed them via route.fetch, no crash).
|
|
185
|
+
assert server.hits_for("/binary-post") == 1
|
|
186
|
+
assert server.last_body("/binary-post") == self.BINARY_BODY
|
|
187
|
+
|
|
188
|
+
def test_binary_post_to_private_blocked(self, isolated, monkeypatch):
|
|
189
|
+
server = isolated
|
|
190
|
+
driver = "/binary-post-driver?to=/binary-post-private"
|
|
191
|
+
monkeypatch.setattr(
|
|
192
|
+
webget,
|
|
193
|
+
"_is_private_target",
|
|
194
|
+
_fake_public_policy(server, extra_public=(driver,)),
|
|
195
|
+
)
|
|
196
|
+
server.reset_counters()
|
|
197
|
+
res = asyncio.run(_browser_fetch(server.url(driver)))
|
|
198
|
+
out = res[server.url(driver)]
|
|
199
|
+
assert out["status"] == "success", out # driver page itself is fine
|
|
200
|
+
# The private sink must NEVER receive the binary POST: with the
|
|
201
|
+
# 0.7.1 bug the guard crashed before the private check and the
|
|
202
|
+
# request leaked through (native continue). Zero hits = no bypass.
|
|
203
|
+
assert server.hits_for("/binary-post-private") == 0, (
|
|
204
|
+
f"private binary POST leaked {server.hits_for('/binary-post-private')} request(s)"
|
|
205
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
5
|
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -414,6 +414,22 @@ class SSRFError(RuntimeError):
|
|
|
414
414
|
"""Raised when a fetch would target a private address."""
|
|
415
415
|
|
|
416
416
|
|
|
417
|
+
def _request_body_bytes(request):
|
|
418
|
+
"""Raw request body as bytes for redirect replay, or None.
|
|
419
|
+
|
|
420
|
+
Playwright's `request.post_data` decodes the body as UTF-8 text and
|
|
421
|
+
raises UnicodeDecodeError on binary/compressed bodies (e.g. gzip
|
|
422
|
+
POSTs, seen on facebook/linkedin). The browser SSRF route guard
|
|
423
|
+
replays the raw body on manual redirect hops, so it must read the
|
|
424
|
+
undecoded bytes. Body handling is BEST-EFFORT and must never be part
|
|
425
|
+
of the SSRF decision, which is URL/IP policy only.
|
|
426
|
+
"""
|
|
427
|
+
try:
|
|
428
|
+
return request.post_data_buffer
|
|
429
|
+
except Exception: # noqa: BLE001 - body replay is best-effort
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
|
|
417
433
|
async def _guard_browser_routes(crawler_ctx):
|
|
418
434
|
"""Install a Playwright route guard so the browser can never request a
|
|
419
435
|
private address, even through redirects or subresources.
|
|
@@ -443,7 +459,9 @@ async def _guard_browser_routes(crawler_ctx):
|
|
|
443
459
|
# method/body handling: 301/302/303 upgrades redirects to GET per
|
|
444
460
|
# HTTP spec; 307/308 preserve method+body.
|
|
445
461
|
method = request.method
|
|
446
|
-
|
|
462
|
+
# post_data decodes as UTF-8 and raises on binary/compressed
|
|
463
|
+
# bodies; read the undecoded bytes instead (best-effort replay).
|
|
464
|
+
body = _request_body_bytes(request)
|
|
447
465
|
|
|
448
466
|
for _hop in range(21):
|
|
449
467
|
if _is_private_target(url):
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|