webget-cli 0.8.0__tar.gz → 0.10.0__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.8.0/webget_cli.egg-info → webget_cli-0.10.0}/PKG-INFO +9 -3
- {webget_cli-0.8.0 → webget_cli-0.10.0}/README.md +8 -2
- {webget_cli-0.8.0 → webget_cli-0.10.0}/pyproject.toml +3 -2
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_adversarial_http.py +21 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_adversarial_ssrf.py +3 -3
- webget_cli-0.10.0/tests/test_login_flow.py +180 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_mcp_profile.py +63 -1
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_mcp_server.py +3 -1
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_security_review.py +6 -6
- webget_cli-0.10.0/tests/test_size_review.py +109 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_webget.py +131 -7
- webget_cli-0.10.0/webget/__init__.py +144 -0
- webget_cli-0.10.0/webget/cache.py +187 -0
- webget_cli-0.10.0/webget/cli.py +377 -0
- webget_cli-0.10.0/webget/firecrawl.py +60 -0
- webget_cli-0.10.0/webget/http.py +175 -0
- webget_cli-0.10.0/webget/ladder.py +576 -0
- webget_cli-0.10.0/webget/profile.py +409 -0
- webget_cli-0.10.0/webget/search.py +37 -0
- webget_cli-0.10.0/webget/ssrf.py +232 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0/webget_cli.egg-info}/PKG-INFO +9 -3
- {webget_cli-0.8.0 → webget_cli-0.10.0}/webget_cli.egg-info/SOURCES.txt +10 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/webget_cli.egg-info/entry_points.txt +1 -1
- {webget_cli-0.8.0 → webget_cli-0.10.0}/webget_cli.egg-info/top_level.txt +1 -0
- webget_cli-0.10.0/webget_cli.py +48 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/webget_mcp.py +53 -0
- webget_cli-0.8.0/tests/test_size_review.py +0 -80
- webget_cli-0.8.0/webget_cli.py +0 -1419
- {webget_cli-0.8.0 → webget_cli-0.10.0}/LICENSE +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/setup.cfg +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_adversarial_mcp.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_mcp_leak_review.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.8.0 → webget_cli-0.10.0}/webget_cli.egg-info/requires.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
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: Apache-2.0
|
|
@@ -247,7 +247,7 @@ standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
|
247
247
|
### Authenticated sessions (profiles)
|
|
248
248
|
|
|
249
249
|
MCP tools can use locally stored login sessions. Create one first with the
|
|
250
|
-
CLI:
|
|
250
|
+
CLI, or let the agent create it via the MCP `login` tool:
|
|
251
251
|
|
|
252
252
|
```bash
|
|
253
253
|
webget login https://portal.example.com --profile portal
|
|
@@ -257,13 +257,19 @@ Then the agent can discover sessions and fetch authenticated pages:
|
|
|
257
257
|
|
|
258
258
|
- `list_profiles` - lists available sessions (name, last used, size,
|
|
259
259
|
status). Cookie values are never returned.
|
|
260
|
+
- `login(url, profile)` - opens a browser session (headful by default so a
|
|
261
|
+
human can log in), navigates to `url`, and persists the session once the
|
|
262
|
+
login handshake's cookies appear (or after `wait_seconds`, whichever
|
|
263
|
+
comes first). MCP stdin is the JSON-RPC stream, so there is no Enter
|
|
264
|
+
keypress; the flow polls for cookies instead.
|
|
260
265
|
- `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
|
|
261
266
|
scrape using that session.
|
|
262
267
|
|
|
263
268
|
```text
|
|
264
269
|
agent: "check my portal for new announcements"
|
|
265
270
|
1. list_profiles -> portal (authenticated)
|
|
266
|
-
2.
|
|
271
|
+
2. login(https://portal.example.com, profile="portal") # if not listed
|
|
272
|
+
3. fetch(url, profile="portal")
|
|
267
273
|
```
|
|
268
274
|
|
|
269
275
|
Invalid profile names and unknown profiles are hard errors (no silent
|
|
@@ -213,7 +213,7 @@ standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
|
213
213
|
### Authenticated sessions (profiles)
|
|
214
214
|
|
|
215
215
|
MCP tools can use locally stored login sessions. Create one first with the
|
|
216
|
-
CLI:
|
|
216
|
+
CLI, or let the agent create it via the MCP `login` tool:
|
|
217
217
|
|
|
218
218
|
```bash
|
|
219
219
|
webget login https://portal.example.com --profile portal
|
|
@@ -223,13 +223,19 @@ Then the agent can discover sessions and fetch authenticated pages:
|
|
|
223
223
|
|
|
224
224
|
- `list_profiles` - lists available sessions (name, last used, size,
|
|
225
225
|
status). Cookie values are never returned.
|
|
226
|
+
- `login(url, profile)` - opens a browser session (headful by default so a
|
|
227
|
+
human can log in), navigates to `url`, and persists the session once the
|
|
228
|
+
login handshake's cookies appear (or after `wait_seconds`, whichever
|
|
229
|
+
comes first). MCP stdin is the JSON-RPC stream, so there is no Enter
|
|
230
|
+
keypress; the flow polls for cookies instead.
|
|
226
231
|
- `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
|
|
227
232
|
scrape using that session.
|
|
228
233
|
|
|
229
234
|
```text
|
|
230
235
|
agent: "check my portal for new announcements"
|
|
231
236
|
1. list_profiles -> portal (authenticated)
|
|
232
|
-
2.
|
|
237
|
+
2. login(https://portal.example.com, profile="portal") # if not listed
|
|
238
|
+
3. fetch(url, profile="portal")
|
|
233
239
|
```
|
|
234
240
|
|
|
235
241
|
Invalid profile names and unknown profiles are hard errors (no silent
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webget-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.0"
|
|
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"
|
|
@@ -39,11 +39,12 @@ mcp = ["fastmcp>=2"]
|
|
|
39
39
|
dev = ["pytest>=8", "ruff>=0.6"]
|
|
40
40
|
|
|
41
41
|
[project.scripts]
|
|
42
|
-
webget = "
|
|
42
|
+
webget = "webget.cli:main"
|
|
43
43
|
webget-mcp = "webget_mcp:main"
|
|
44
44
|
|
|
45
45
|
[tool.setuptools]
|
|
46
46
|
py-modules = ["webget_cli", "webget_mcp"]
|
|
47
|
+
packages = ["webget"]
|
|
47
48
|
|
|
48
49
|
[tool.pytest.ini_options]
|
|
49
50
|
testpaths = ["tests"]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Adversarial HTTP tests: URL handling, status codes, redirects, bodies."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import time
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
@@ -63,6 +64,26 @@ class TestConnectionFailures:
|
|
|
63
64
|
res = asyncio.run(_fetch(server.url("/timeout?sec=5"), per_url_timeout=1))
|
|
64
65
|
assert _one(res)["status"] == "error"
|
|
65
66
|
|
|
67
|
+
def test_slow_drip_hits_wall_clock_deadline(self, fresh_cache):
|
|
68
|
+
"""A server that slow-drips the body must not exceed per_url_timeout.
|
|
69
|
+
|
|
70
|
+
httpx's own timeout bounds a single socket operation only, so a
|
|
71
|
+
drip of small chunks every 150ms (total ~6s for 40 chunks) would
|
|
72
|
+
keep the request alive far past a 2s budget. fetch_http enforces
|
|
73
|
+
an absolute deadline while streaming; this proves the URL errors
|
|
74
|
+
out in roughly the budget, not the full drip duration.
|
|
75
|
+
"""
|
|
76
|
+
server = fresh_cache
|
|
77
|
+
t0 = time.monotonic()
|
|
78
|
+
res = asyncio.run(
|
|
79
|
+
_fetch(server.url("/drip?n=40&delay=0.15"), per_url_timeout=2, strategy="http")
|
|
80
|
+
)
|
|
81
|
+
elapsed = time.monotonic() - t0
|
|
82
|
+
out = _one(res)
|
|
83
|
+
assert out["status"] == "error"
|
|
84
|
+
# Drip would take ~6s uncapped; deadline must cut it near 2s.
|
|
85
|
+
assert elapsed < 4.0, f"deadline not enforced: took {elapsed:.1f}s"
|
|
86
|
+
|
|
66
87
|
|
|
67
88
|
# ---------- status codes ----------
|
|
68
89
|
|
|
@@ -98,14 +98,14 @@ class TestSSRFBlocking:
|
|
|
98
98
|
the initial URL passed the guard (e.g. it was public)."""
|
|
99
99
|
import pytest
|
|
100
100
|
|
|
101
|
-
real = webget.
|
|
101
|
+
real = webget._private_ip_for
|
|
102
102
|
initial = server.url("/redirect-private")
|
|
103
103
|
|
|
104
104
|
def guarded(url):
|
|
105
105
|
# initial URL passes (as if public); every other URL checked for real
|
|
106
|
-
return real(url) if url != initial else
|
|
106
|
+
return real(url) if url != initial else None
|
|
107
107
|
|
|
108
|
-
monkeypatch.setattr(webget, "
|
|
108
|
+
monkeypatch.setattr(webget, "_private_ip_for", guarded)
|
|
109
109
|
with pytest.raises(webget.SSRFError):
|
|
110
110
|
asyncio.run(webget.fetch_http(initial, 2000, timeout=5))
|
|
111
111
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""MCP login tool + non-interactive login flow tests.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- _login_flow(interactive=False): polls browser context until a Set-Cookie
|
|
5
|
+
arrives (here: /set-cookie on the local test server), then persists a
|
|
6
|
+
storage_state.json the HTTP fast path can reuse.
|
|
7
|
+
- MCP login tool: validates URL/profile, runs the flow headless against
|
|
8
|
+
the local server, then fetch(profile=...) uses the stored session.
|
|
9
|
+
- login tool rejects bad URLs and unknown/invalid profiles.
|
|
10
|
+
|
|
11
|
+
The test server lives on 127.0.0.1, so WEBGET_ALLOW_PRIVATE=1 is required
|
|
12
|
+
for the subsequent authenticated fetch through the SSRF-guarded HTTP path.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
sys.path.insert(0, str(ROOT))
|
|
25
|
+
|
|
26
|
+
import webget_cli as webget
|
|
27
|
+
|
|
28
|
+
playwright = pytest.importorskip("playwright", reason="playwright not installed")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _profile_root(tmp_path):
|
|
32
|
+
return tmp_path / "profiles"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_login_flow_noninteractive_persists_session(server, tmp_path, monkeypatch):
|
|
36
|
+
"""Browser-less-enough path: headless Chromium navigates /set-cookie,
|
|
37
|
+
cookie polling sees the Set-Cookie side effect, storage_state persists,
|
|
38
|
+
and the HTTP fast path can reuse it on /cookie-gated."""
|
|
39
|
+
root = _profile_root(tmp_path)
|
|
40
|
+
monkeypatch.setattr(webget, "PROFILE_DIR", str(root))
|
|
41
|
+
monkeypatch.setenv("WEBGET_ALLOW_PRIVATE", "1")
|
|
42
|
+
|
|
43
|
+
asyncio.run(
|
|
44
|
+
webget._login_flow(
|
|
45
|
+
server.url("/set-cookie"),
|
|
46
|
+
"flowp",
|
|
47
|
+
headless=True,
|
|
48
|
+
wait_seconds=30,
|
|
49
|
+
interactive=False,
|
|
50
|
+
quiet=True,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
state_p = webget.profile_state_path("flowp")
|
|
55
|
+
assert os.path.exists(state_p)
|
|
56
|
+
state = json.loads(Path(state_p).read_text())
|
|
57
|
+
cookies = {c["name"]: c["value"] for c in state["cookies"]}
|
|
58
|
+
assert cookies.get("webget_session") == "1"
|
|
59
|
+
|
|
60
|
+
# HTTP fast path must now authenticate on the cookie-gated page.
|
|
61
|
+
res = asyncio.run(
|
|
62
|
+
webget.scrape_many(
|
|
63
|
+
[server.url("/cookie-gated")],
|
|
64
|
+
strategy="http",
|
|
65
|
+
no_cache=True,
|
|
66
|
+
profile="flowp",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
out = res[server.url("/cookie-gated")]
|
|
70
|
+
assert out["status"] == "success"
|
|
71
|
+
assert out["auth"]["authenticated"] is True
|
|
72
|
+
assert "you are authenticated" in out["markdown"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_login_flow_waits_until_cookie(server, tmp_path, monkeypatch):
|
|
76
|
+
"""A page that does NOT set a cookie still persists (best-effort), and a
|
|
77
|
+
slow Set-Cookie is picked up by polling before the deadline."""
|
|
78
|
+
root = _profile_root(tmp_path)
|
|
79
|
+
monkeypatch.setattr(webget, "PROFILE_DIR", str(root))
|
|
80
|
+
|
|
81
|
+
asyncio.run(
|
|
82
|
+
webget._login_flow(
|
|
83
|
+
server.url("/set-cookie"),
|
|
84
|
+
"slowp",
|
|
85
|
+
headless=True,
|
|
86
|
+
wait_seconds=30,
|
|
87
|
+
interactive=False,
|
|
88
|
+
quiet=True,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
state_p = webget.profile_state_path("slowp")
|
|
92
|
+
assert os.path.exists(state_p)
|
|
93
|
+
cookies = {c["name"] for c in json.loads(Path(state_p).read_text())["cookies"]}
|
|
94
|
+
assert "webget_session" in cookies
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _spawn_mcp(profile_root):
|
|
98
|
+
from mcp import StdioServerParameters
|
|
99
|
+
|
|
100
|
+
env = {
|
|
101
|
+
**os.environ,
|
|
102
|
+
"WEBGET_PROFILE_DIR": str(profile_root),
|
|
103
|
+
"WEBGET_ALLOW_PRIVATE": "1",
|
|
104
|
+
}
|
|
105
|
+
return StdioServerParameters(command=sys.executable, args=[str(ROOT / "webget_mcp.py")], env=env)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_mcp_login_tool_persists_and_fetch_uses_it(server, tmp_path):
|
|
109
|
+
"""End-to-end MCP: login(url, profile, headless=True) against the local
|
|
110
|
+
/set-cookie page persists a session, then fetch(profile=...) on the
|
|
111
|
+
cookie-gated page succeeds while anonymous fetch does not."""
|
|
112
|
+
from mcp import ClientSession
|
|
113
|
+
from mcp.client.stdio import stdio_client
|
|
114
|
+
|
|
115
|
+
root = _profile_root(tmp_path)
|
|
116
|
+
login_url = server.url("/set-cookie")
|
|
117
|
+
gated_url = server.url("/cookie-gated")
|
|
118
|
+
|
|
119
|
+
async def run():
|
|
120
|
+
async with stdio_client(_spawn_mcp(root)) as (read, write), ClientSession(read, write) as session:
|
|
121
|
+
await session.initialize()
|
|
122
|
+
res = await session.call_tool(
|
|
123
|
+
"login",
|
|
124
|
+
{"url": login_url, "profile": "mcplogin", "headless": True, "wait_seconds": 30},
|
|
125
|
+
)
|
|
126
|
+
authed = await session.call_tool(
|
|
127
|
+
"fetch", {"url": gated_url, "strategy": "http", "no_cache": True, "profile": "mcplogin"}
|
|
128
|
+
)
|
|
129
|
+
anon = await session.call_tool(
|
|
130
|
+
"fetch", {"url": gated_url, "strategy": "http", "no_cache": True}
|
|
131
|
+
)
|
|
132
|
+
return res, authed, anon
|
|
133
|
+
|
|
134
|
+
res, authed, anon = asyncio.run(asyncio.wait_for(run(), timeout=90))
|
|
135
|
+
res_text = res.content[0].text if res.content else ""
|
|
136
|
+
assert not res.isError, res_text
|
|
137
|
+
assert '"status":"success"' in res_text, res_text
|
|
138
|
+
assert '"profile":"mcplogin"' in res_text
|
|
139
|
+
|
|
140
|
+
authed_text = authed.content[0].text if authed.content else ""
|
|
141
|
+
assert '"status":"success"' in authed_text, authed_text
|
|
142
|
+
assert "you are authenticated" in authed_text
|
|
143
|
+
assert '"authenticated":true' in authed_text
|
|
144
|
+
|
|
145
|
+
anon_text = anon.content[0].text if anon.content else ""
|
|
146
|
+
assert '"status":"success"' not in anon_text, f"anonymous fetch leaked: {anon_text}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_mcp_login_rejects_bad_url_and_profile(server, tmp_path):
|
|
150
|
+
"""login tool validates its inputs: bad URL and bad profile name are
|
|
151
|
+
clean errors, never a crash."""
|
|
152
|
+
from mcp import ClientSession
|
|
153
|
+
from mcp.client.stdio import stdio_client
|
|
154
|
+
|
|
155
|
+
root = _profile_root(tmp_path)
|
|
156
|
+
|
|
157
|
+
async def run():
|
|
158
|
+
async with stdio_client(_spawn_mcp(root)) as (read, write), ClientSession(read, write) as session:
|
|
159
|
+
await session.initialize()
|
|
160
|
+
bad_url = await session.call_tool(
|
|
161
|
+
"login", {"url": "not a url", "profile": "x", "headless": True, "wait_seconds": 5}
|
|
162
|
+
)
|
|
163
|
+
bad_name = await session.call_tool(
|
|
164
|
+
"login", {"url": server.url("/"), "profile": "../evil", "headless": True, "wait_seconds": 5}
|
|
165
|
+
)
|
|
166
|
+
bad_secs = await session.call_tool(
|
|
167
|
+
"login", {"url": server.url("/"), "profile": "x", "headless": True, "wait_seconds": 1}
|
|
168
|
+
)
|
|
169
|
+
return bad_url, bad_name, bad_secs
|
|
170
|
+
|
|
171
|
+
bad_url, bad_name, bad_secs = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
172
|
+
assert "invalid site URL" in bad_url.content[0].text
|
|
173
|
+
assert "invalid profile name" in bad_name.content[0].text
|
|
174
|
+
assert "wait_seconds must be between 5 and 600" in bad_secs.content[0].text
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
import pytest
|
|
179
|
+
|
|
180
|
+
sys.exit(pytest.main([__file__, "-v"]))
|
|
@@ -43,7 +43,7 @@ def _make_profile(root, name):
|
|
|
43
43
|
{
|
|
44
44
|
"cookies": [
|
|
45
45
|
{
|
|
46
|
-
"name": "
|
|
46
|
+
"name": "session",
|
|
47
47
|
"value": "1",
|
|
48
48
|
"domain": "127.0.0.1",
|
|
49
49
|
"path": "/",
|
|
@@ -109,6 +109,68 @@ def test_mcp_fetch_unknown_profile_is_hard_error(tmp_path):
|
|
|
109
109
|
assert "profile 'ghost' not found" in res.content[0].text
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def test_mcp_fetch_enriched_output_present(tmp_path):
|
|
113
|
+
"""MCP fetch must include auth, attempts, and reasons in the response.
|
|
114
|
+
|
|
115
|
+
These fields give agents full provenance: whether the fetch used a
|
|
116
|
+
session, how many ladder steps were tried, and the full chain of
|
|
117
|
+
failures. Added 2026-08-31.
|
|
118
|
+
"""
|
|
119
|
+
server = TestServer().start()
|
|
120
|
+
try:
|
|
121
|
+
root = tmp_path / "profiles"
|
|
122
|
+
_make_profile(root, "testp")
|
|
123
|
+
gated = server.url("/cookie-gated")
|
|
124
|
+
|
|
125
|
+
async def run():
|
|
126
|
+
async with (
|
|
127
|
+
stdio_client(_spawn(root)) as (read, write),
|
|
128
|
+
ClientSession(read, write) as session,
|
|
129
|
+
):
|
|
130
|
+
await session.initialize()
|
|
131
|
+
# Authenticated fetch
|
|
132
|
+
ok = await session.call_tool(
|
|
133
|
+
"fetch",
|
|
134
|
+
{
|
|
135
|
+
"url": gated,
|
|
136
|
+
"strategy": "http",
|
|
137
|
+
"no_cache": True,
|
|
138
|
+
"profile": "testp",
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
# Anonymous fetch (blocked)
|
|
142
|
+
anon = await session.call_tool(
|
|
143
|
+
"fetch",
|
|
144
|
+
{"url": gated, "strategy": "http", "no_cache": True},
|
|
145
|
+
)
|
|
146
|
+
return ok, anon
|
|
147
|
+
|
|
148
|
+
ok, anon = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
149
|
+
ok_text = ok.content[0].text if ok.content else ""
|
|
150
|
+
anon_text = anon.content[0].text if anon.content else ""
|
|
151
|
+
|
|
152
|
+
# Authenticated: standard assertions
|
|
153
|
+
assert not ok.isError, ok_text
|
|
154
|
+
assert '"status":"success"' in ok_text
|
|
155
|
+
# auth
|
|
156
|
+
assert '"auth"' in ok_text
|
|
157
|
+
assert '"authenticated":true' in ok_text
|
|
158
|
+
assert '"attempts"' in ok_text
|
|
159
|
+
# Success carries an empty reasons list (consistent shape: consumers
|
|
160
|
+
# can always iterate reasons without a None check).
|
|
161
|
+
assert '"reasons":[]' in ok_text, "success should have empty reasons"
|
|
162
|
+
|
|
163
|
+
# Anonymous (blocked): must have provenance fields
|
|
164
|
+
assert '"status":"blocked"' in anon_text or '"status":"login_required"' in anon_text
|
|
165
|
+
assert '"auth"' in anon_text
|
|
166
|
+
assert '"authenticated":false' in anon_text or '"authenticated":null' in anon_text
|
|
167
|
+
assert '"attempts"' in anon_text
|
|
168
|
+
assert '"reasons"' in anon_text
|
|
169
|
+
assert '"state":"blocked"' in anon_text or '"state":"login_required"' in anon_text
|
|
170
|
+
finally:
|
|
171
|
+
server.stop()
|
|
172
|
+
|
|
173
|
+
|
|
112
174
|
if __name__ == "__main__":
|
|
113
175
|
import pytest
|
|
114
176
|
|
|
@@ -30,7 +30,9 @@ def test_tools_listed():
|
|
|
30
30
|
return [t.name for t in tools.tools]
|
|
31
31
|
|
|
32
32
|
# order is not a contract; membership is
|
|
33
|
-
assert sorted(_run(run())) == sorted(
|
|
33
|
+
assert sorted(_run(run())) == sorted(
|
|
34
|
+
["search", "fetch", "search_fetch", "list_profiles", "login"]
|
|
35
|
+
)
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def test_invalid_strategy_returns_error_not_crash():
|
|
@@ -83,30 +83,30 @@ class TestRedirectHopPolicy:
|
|
|
83
83
|
hop check must fire even though the initial URL passed."""
|
|
84
84
|
import pytest
|
|
85
85
|
|
|
86
|
-
real = webget.
|
|
86
|
+
real = webget._private_ip_for
|
|
87
87
|
initial = server.url("/redirect-private")
|
|
88
88
|
|
|
89
89
|
def guarded(url):
|
|
90
|
-
return real(url) if url != initial else
|
|
90
|
+
return real(url) if url != initial else None
|
|
91
91
|
|
|
92
|
-
monkeypatch.setattr(webget, "
|
|
92
|
+
monkeypatch.setattr(webget, "_private_ip_for", guarded)
|
|
93
93
|
with pytest.raises(webget.SSRFError):
|
|
94
94
|
asyncio.run(webget.fetch_http(initial, 2000, timeout=5))
|
|
95
95
|
|
|
96
96
|
def test_multi_hop_into_private_blocked(self, server, isolated_env, monkeypatch):
|
|
97
97
|
"""public -> public -> private (3 hops) must still be blocked."""
|
|
98
98
|
|
|
99
|
-
real = webget.
|
|
99
|
+
real = webget._private_ip_for
|
|
100
100
|
initial = server.url("/redirect-chain?n=2") # -> /redirect-chain?n=1 -> /normal
|
|
101
101
|
# craft: first two hops pass (public), third hop lands on /private
|
|
102
102
|
chain = [server.url("/redirect-chain?n=2"), server.url("/redirect-chain?n=1")]
|
|
103
103
|
|
|
104
104
|
def guarded(url):
|
|
105
105
|
if url in chain or url == server.url("/redirect-chain?n=2"):
|
|
106
|
-
return
|
|
106
|
+
return None
|
|
107
107
|
return real(url)
|
|
108
108
|
|
|
109
|
-
monkeypatch.setattr(webget, "
|
|
109
|
+
monkeypatch.setattr(webget, "_private_ip_for", guarded)
|
|
110
110
|
# fetch_http follows hops manually; every hop re-checks
|
|
111
111
|
try:
|
|
112
112
|
asyncio.run(webget.fetch_http(initial, 2000, timeout=5))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Phase 11 response-size-limit review tests.
|
|
2
|
+
|
|
3
|
+
The cap must be enforced WHILE STREAMING (client.stream), not after the
|
|
4
|
+
body has been buffered. /oversize serves 30MB; a non-streaming client
|
|
5
|
+
would buffer all 30MB before the check could fire.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import webget_cli as webget
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _fetch(url, **kw):
|
|
15
|
+
# Default to explicit http strategy: these tests measure the HTTP
|
|
16
|
+
# streaming cap, not the ladder. With strategy="auto" a too-large
|
|
17
|
+
# response would escalate to Crawl4AI (browser import ~40s), which
|
|
18
|
+
# pollutes the wall-time assertion.
|
|
19
|
+
kw.setdefault("strategy", "http")
|
|
20
|
+
return await webget.scrape_many([url], no_cache=True, **kw)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _one(res):
|
|
24
|
+
return res[next(iter(res))]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestSizeLimitEnforcement:
|
|
28
|
+
def test_oversize_returns_error(self, fresh_cache):
|
|
29
|
+
server = fresh_cache
|
|
30
|
+
res = asyncio.run(_fetch(server.url("/oversize")))
|
|
31
|
+
out = _one(res)
|
|
32
|
+
assert out["status"] == "error"
|
|
33
|
+
assert "too large" in (out.get("error") or "").lower()
|
|
34
|
+
|
|
35
|
+
def test_undersize_still_works(self, fresh_cache):
|
|
36
|
+
server = fresh_cache
|
|
37
|
+
res = asyncio.run(_fetch(server.url("/huge")))
|
|
38
|
+
assert _one(res)["status"] == "success"
|
|
39
|
+
|
|
40
|
+
def test_streaming_actually_bounded(self, fresh_cache):
|
|
41
|
+
"""The cap MUST abort mid-stream, not buffer the full 30MB.
|
|
42
|
+
|
|
43
|
+
Verified by wall time: if the client buffered 30MB over localhost
|
|
44
|
+
the wall time would be under 1s (fast local pipe). But the
|
|
45
|
+
streaming cap fires at 25MB, so the function returns quickly
|
|
46
|
+
with an error -- well before a full 30MB read + extraction.
|
|
47
|
+
The maxrss measurement is unreliable here because the test
|
|
48
|
+
server runs in-process (same memory space), so we rely on
|
|
49
|
+
wall time as a proxy: a full 30MB read + extraction would take
|
|
50
|
+
noticeably longer.
|
|
51
|
+
"""
|
|
52
|
+
server = fresh_cache
|
|
53
|
+
t0 = time.perf_counter()
|
|
54
|
+
res = asyncio.run(_fetch(server.url("/oversize")))
|
|
55
|
+
wall = time.perf_counter() - t0
|
|
56
|
+
out = _one(res)
|
|
57
|
+
assert out["status"] == "error"
|
|
58
|
+
assert "too large" in (out.get("error") or "").lower()
|
|
59
|
+
# Local server: 25MB cap should fire in < 5s. If the cap broke
|
|
60
|
+
# and the client read all 30MB, extraction adds significant time.
|
|
61
|
+
assert wall < 15, f"oversize fetch took {wall}s (looks like full read)"
|
|
62
|
+
|
|
63
|
+
def test_missing_content_length(self, fresh_cache):
|
|
64
|
+
"""Chunked/unknown-length responses must still hit the cap."""
|
|
65
|
+
# /oversize declares Content-Length, but the cap check does not
|
|
66
|
+
# depend on it; a chunked server would behave the same. This test
|
|
67
|
+
# documents the contract: cap is on bytes read, not on headers.
|
|
68
|
+
server = fresh_cache
|
|
69
|
+
res = asyncio.run(_fetch(server.url("/oversize")))
|
|
70
|
+
assert _one(res)["status"] == "error"
|
|
71
|
+
|
|
72
|
+
def test_too_large_is_terminal_not_escalated(self, fresh_cache, monkeypatch):
|
|
73
|
+
"""ResponseTooLarge must NOT trigger the crawl4ai ladder step.
|
|
74
|
+
|
|
75
|
+
Before this fix, any http exception (including the streaming cap)
|
|
76
|
+
returned None from record(), leaving the URL pending, so the
|
|
77
|
+
ladder imported crawl4ai and launched a browser to re-download
|
|
78
|
+
the same 30MB body. The cap is terminal: report the error, drop
|
|
79
|
+
the URL, never open a browser.
|
|
80
|
+
"""
|
|
81
|
+
server = fresh_cache
|
|
82
|
+
# The test server's /oversize is on 127.0.0.1 (private), so allow it.
|
|
83
|
+
monkeypatch.setenv("WEBGET_ALLOW_PRIVATE", "1")
|
|
84
|
+
res = asyncio.run(_fetch(server.url("/oversize")))
|
|
85
|
+
out = _one(res)
|
|
86
|
+
# Terminal: status=error from the http step, not escalated.
|
|
87
|
+
assert out["status"] == "error"
|
|
88
|
+
assert out["method"] == "http"
|
|
89
|
+
assert "too large" in (out.get("error") or "").lower()
|
|
90
|
+
# It must resolve in one step (no browser re-download), so the
|
|
91
|
+
# wall time stays low. (The ladder would have imported crawl4ai
|
|
92
|
+
# and launched Chromium otherwise, which is far slower.)
|
|
93
|
+
assert out["attempts"] == 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestBrowserEquivalent:
|
|
97
|
+
def test_browser_has_no_byte_cap_documented(self):
|
|
98
|
+
"""The browser strategy loads full pages (JS rendering) and has no
|
|
99
|
+
equivalent byte cap: crawl4ai returns whatever Chromium parsed.
|
|
100
|
+
This is a documented difference, not a silent gap: page size is
|
|
101
|
+
bounded in practice by Chromium's own resource limits and by
|
|
102
|
+
max_chars truncation AFTER parsing. A 30MB page would still be
|
|
103
|
+
parsed by the browser. Acceptable risk for the browser path."""
|
|
104
|
+
import inspect
|
|
105
|
+
|
|
106
|
+
src = inspect.getsource(webget.scrape_many)
|
|
107
|
+
assert "MAX_RESPONSE_BYTES" in inspect.getsource(webget) # exists
|
|
108
|
+
# The browser path intentionally relies on crawl4ai's own handling.
|
|
109
|
+
assert "crawl4ai" in src
|