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.
Files changed (31) hide show
  1. {webget_cli-0.7.1 → webget_cli-0.7.2}/PKG-INFO +1 -1
  2. {webget_cli-0.7.1 → webget_cli-0.7.2}/pyproject.toml +1 -1
  3. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_ssrf.py +38 -0
  4. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_browser_ssrf.py +56 -0
  5. {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/PKG-INFO +1 -1
  6. {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.py +19 -1
  7. {webget_cli-0.7.1 → webget_cli-0.7.2}/LICENSE +0 -0
  8. {webget_cli-0.7.1 → webget_cli-0.7.2}/README.md +0 -0
  9. {webget_cli-0.7.1 → webget_cli-0.7.2}/setup.cfg +0 -0
  10. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_auth.py +0 -0
  11. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_cache.py +0 -0
  12. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_concurrency.py +0 -0
  13. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_http.py +0 -0
  14. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_adversarial_mcp.py +0 -0
  15. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_auth_review.py +0 -0
  16. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_cache_review.py +0 -0
  17. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_concurrency_review.py +0 -0
  18. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_firecrawl_policy.py +0 -0
  19. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_integration_ladder.py +0 -0
  20. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_mcp_leak_review.py +0 -0
  21. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_mcp_server.py +0 -0
  22. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_mcp_smoke.py +0 -0
  23. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_security_review.py +0 -0
  24. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_size_review.py +0 -0
  25. {webget_cli-0.7.1 → webget_cli-0.7.2}/tests/test_webget.py +0 -0
  26. {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/SOURCES.txt +0 -0
  27. {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/dependency_links.txt +0 -0
  28. {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/entry_points.txt +0 -0
  29. {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/requires.txt +0 -0
  30. {webget_cli-0.7.1 → webget_cli-0.7.2}/webget_cli.egg-info/top_level.txt +0 -0
  31. {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.1
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.1"
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.1
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
- body = request.post_data
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