tollbooth 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 libcaptcha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: tollbooth
3
+ Version: 0.1.0
4
+ Summary: Proof-of-work bot challenge middleware
5
+ Requires-Python: >=3.11
6
+ License-File: LICENSE
7
+ Provides-Extra: test
8
+ Requires-Dist: pytest; extra == "test"
9
+ Requires-Dist: pytest-asyncio; extra == "test"
10
+ Requires-Dist: httpx; extra == "test"
11
+ Provides-Extra: django
12
+ Requires-Dist: django>=4.0; extra == "django"
13
+ Provides-Extra: flask
14
+ Requires-Dist: flask>=2.0; extra == "flask"
15
+ Provides-Extra: fastapi
16
+ Requires-Dist: fastapi>=0.100; extra == "fastapi"
17
+ Provides-Extra: starlette
18
+ Requires-Dist: starlette>=0.27; extra == "starlette"
19
+ Provides-Extra: falcon
20
+ Requires-Dist: falcon>=3.0; extra == "falcon"
21
+ Dynamic: license-file
@@ -0,0 +1,373 @@
1
+ <div align="center">
2
+
3
+ # tollbooth
4
+
5
+ Proof-of-work bot challenge middleware for Python. Zero dependencies.
6
+
7
+ </div>
8
+
9
+ ```python
10
+ from fastapi import FastAPI, Depends
11
+ from tollbooth.integrations.fastapi import TollboothMiddleware
12
+
13
+ app = FastAPI()
14
+ app.add_middleware(TollboothMiddleware, secret="your-secret-key")
15
+ ```
16
+
17
+ Bots get a browser challenge page. Humans solve it once, get a cookie, browse freely.
18
+
19
+ ## Why tollbooth over [Anubis](https://github.com/TecharoHQ/anubis)?
20
+
21
+ | | tollbooth | Anubis |
22
+ | ----------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
23
+ | **Language** | Python (drop-in middleware) | Go (standalone reverse proxy) |
24
+ | **Dependencies** | 0 | [31 direct, ~160 transitive](https://github.com/TecharoHQ/anubis/blob/main/go.mod#L3-L203) |
25
+ | **Code size** | ~800 lines | ~10,000 lines |
26
+ | **Integration** | `app.add_middleware(...)` | Separate process + reverse proxy |
27
+ | **PoW algorithm** | Balloon hashing (memory-hard) | [Plain SHA-256](https://github.com/TecharoHQ/anubis/blob/main/lib/challenge/proofofwork/proofofwork.go#L35-L85) |
28
+ | **Rules format** | JSON | [YAML + CEL expressions](https://github.com/TecharoHQ/anubis/blob/main/lib/config/config.go#L58-L73) |
29
+ | **Frameworks** | Flask, Django, FastAPI, Starlette, Falcon | None (reverse proxy only) |
30
+
31
+ ### Security: memory-hard PoW
32
+
33
+ Anubis uses [plain SHA-256 hashing](https://github.com/TecharoHQ/anubis/blob/main/lib/challenge/proofofwork/proofofwork.go#L35-L85) — fast on GPUs and ASICs. An attacker with a GPU farm can solve challenges orders of magnitude faster than a browser.
34
+
35
+ Tollbooth uses **Balloon hashing** (Boneh, Corrigan-Gibbs, Schechter 2016) — a memory-hard function that requires `spaceCost * 32 bytes` per attempt. GPU parallelism is bottlenecked by memory bandwidth, not compute. This makes mass-solving economically impractical.
36
+
37
+ ### Integration: middleware vs reverse proxy
38
+
39
+ Anubis runs as a [separate process with a reverse proxy](https://github.com/TecharoHQ/anubis/blob/main/cmd/anubis/main.go#L211-L265), adding network hops, deployment complexity, and a new failure domain.
40
+
41
+ Tollbooth is a middleware — it lives in your process, shares your config, and adds zero infrastructure:
42
+
43
+ ```python
44
+ # WSGI (Flask, Django)
45
+ app = TollboothWSGI(app, secret="key")
46
+
47
+ # ASGI (FastAPI, Starlette)
48
+ app = TollboothASGI(app, secret="key")
49
+ ```
50
+
51
+ ### Rules: JSON vs YAML+CEL
52
+
53
+ Anubis requires [YAML policies with optional CEL expressions](https://github.com/TecharoHQ/anubis/blob/main/data/botPolicies.yaml) and [a complex config struct](https://github.com/TecharoHQ/anubis/blob/main/lib/config/config.go#L58-L73) with GeoIP, ASN, Thoth subscriptions, and 30+ CLI flags.
54
+
55
+ Tollbooth: one JSON file, four actions, regex matching.
56
+
57
+ ### Performance: in-process vs network hop
58
+
59
+ Anubis [proxies every request through a separate Go process](https://github.com/TecharoHQ/anubis/blob/main/lib/anubis.go#L187-L296) — the full request pipeline includes reverse proxy setup, header rewriting, and upstream forwarding.
60
+
61
+ Tollbooth evaluates rules in-process with zero serialization. Allowed requests add microseconds of overhead. Challenged requests are handled before your app even sees them.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install tollbooth
67
+ ```
68
+
69
+ With framework extras:
70
+
71
+ ```bash
72
+ pip install tollbooth[flask]
73
+ pip install tollbooth[django]
74
+ pip install tollbooth[fastapi]
75
+ pip install tollbooth[starlette]
76
+ pip install tollbooth[falcon]
77
+ ```
78
+
79
+ ## How it works
80
+
81
+ ```
82
+ Browser Server
83
+ │ │
84
+ │ GET /page │
85
+ │──────────────────────────────────►│
86
+ │ │ rules evaluate request
87
+ │ 429 + challenge page │ → action: challenge
88
+ │◄──────────────────────────────────│
89
+ │ │
90
+ │ Web Workers solve PoW │
91
+ │ Balloon(random_data + nonce) │
92
+ │ until ≥ difficulty leading │
93
+ │ zero bits in hash │
94
+ │ │
95
+ │ POST /.tollbooth/verify │
96
+ │ { id, nonce, redirect } │
97
+ │──────────────────────────────────►│
98
+ │ │ server verifies PoW
99
+ │ 302 + Set-Cookie (JWT) │ → issues signed cookie
100
+ │◄──────────────────────────────────│
101
+ │ │
102
+ │ GET /page (with cookie) │
103
+ │──────────────────────────────────►│
104
+ │ 200 OK │ cookie valid → pass through
105
+ │◄──────────────────────────────────│
106
+ ```
107
+
108
+ The challenge page uses `navigator.hardwareConcurrency` Web Workers to mine in parallel. The JWT cookie is HMAC-SHA256 signed, bound to the client's IP hash, and valid for 7 days.
109
+
110
+ ## Usage
111
+
112
+ ### Raw WSGI / ASGI
113
+
114
+ ```python
115
+ from tollbooth import TollboothWSGI, TollboothASGI
116
+
117
+ # WSGI
118
+ app = TollboothWSGI(your_app, secret="your-secret-key")
119
+
120
+ # ASGI
121
+ app = TollboothASGI(your_app, secret="your-secret-key")
122
+ ```
123
+
124
+ ### Flask
125
+
126
+ ```python
127
+ from flask import Flask
128
+ from tollbooth.integrations.flask import Tollbooth
129
+
130
+ app = Flask(__name__)
131
+ tb = Tollbooth(app, secret="your-secret-key")
132
+
133
+ @app.route("/")
134
+ def index():
135
+ return "Hello!"
136
+
137
+ @tb.exempt
138
+ @app.route("/health")
139
+ def health():
140
+ return "ok"
141
+ ```
142
+
143
+ ### Django
144
+
145
+ ```python
146
+ # settings.py
147
+ TOLLBOOTH = {"secret": "your-secret-key"}
148
+ MIDDLEWARE = [
149
+ "tollbooth.integrations.django.TollboothMiddleware",
150
+ # ...
151
+ ]
152
+ ```
153
+
154
+ Per-view exemption:
155
+
156
+ ```python
157
+ from tollbooth.integrations.django import tollbooth_exempt
158
+
159
+ @tollbooth_exempt
160
+ def health(request):
161
+ return HttpResponse("ok")
162
+ ```
163
+
164
+ ### FastAPI
165
+
166
+ ```python
167
+ from fastapi import FastAPI
168
+ from tollbooth.integrations.fastapi import TollboothMiddleware
169
+
170
+ app = FastAPI()
171
+ app.add_middleware(TollboothMiddleware, secret="your-secret-key")
172
+ ```
173
+
174
+ Or as a dependency for specific routes:
175
+
176
+ ```python
177
+ from tollbooth.integrations.fastapi import TollboothDep
178
+
179
+ protect = TollboothDep("your-secret-key")
180
+
181
+ @app.get("/protected", dependencies=[Depends(protect)])
182
+ def protected():
183
+ return {"ok": True}
184
+ ```
185
+
186
+ ### Starlette
187
+
188
+ ```python
189
+ from starlette.applications import Starlette
190
+ from tollbooth.integrations.starlette import TollboothMiddleware
191
+
192
+ app = Starlette()
193
+ app.add_middleware(TollboothMiddleware, secret="your-secret-key")
194
+ ```
195
+
196
+ ### Falcon
197
+
198
+ ```python
199
+ import falcon
200
+ from tollbooth.integrations.falcon import TollboothMiddleware
201
+
202
+ app = falcon.App(middleware=[
203
+ TollboothMiddleware(secret="your-secret-key"),
204
+ ])
205
+ ```
206
+
207
+ ## Configuration
208
+
209
+ Pass options as keyword arguments to any integration:
210
+
211
+ ```python
212
+ TollboothWSGI(
213
+ app,
214
+ secret="your-secret-key",
215
+ default_difficulty=12, # leading zero bits (default: 10)
216
+ space_cost=2048, # balloon memory blocks (default: 1024)
217
+ time_cost=1, # mixing rounds (default: 1)
218
+ delta=3, # random lookups per step (default: 3)
219
+ cookie_ttl=86400, # cookie lifetime seconds (default: 604800)
220
+ challenge_ttl=1800, # challenge validity seconds (default: 1800)
221
+ challenge_threshold=5, # weight sum to trigger challenge (default: 5)
222
+ branding=True, # show "Protected by tollbooth" (default: True)
223
+ )
224
+ ```
225
+
226
+ Each +1 difficulty doubles expected solve time. Higher `space_cost` increases memory per attempt (`space_cost * 32` bytes).
227
+
228
+ ## Rules
229
+
230
+ Rules are evaluated top-to-bottom. First matching terminal action (`allow`, `deny`, `challenge`) wins. `weigh` rules accumulate weight — if the sum reaches `challenge_threshold`, a challenge is issued.
231
+
232
+ ### Format
233
+
234
+ ```json
235
+ [
236
+ {
237
+ "name": "rule-name",
238
+ "action": "allow | deny | challenge | weigh",
239
+ "user_agent": "regex",
240
+ "path": "regex",
241
+ "headers": { "Header-Name": "regex" },
242
+ "remote_addresses": ["192.168.0.0/24"],
243
+ "difficulty": 12,
244
+ "weight": 3
245
+ }
246
+ ]
247
+ ```
248
+
249
+ All match fields are optional. A rule with no match fields matches everything. All fields use regex except `remote_addresses` (CIDR notation).
250
+
251
+ ### Actions
252
+
253
+ | Action | Behavior |
254
+ | ----------- | ------------------------------------------ |
255
+ | `allow` | Pass through immediately |
256
+ | `deny` | Return 403 |
257
+ | `challenge` | Serve PoW challenge page |
258
+ | `weigh` | Add `weight` to score, continue evaluating |
259
+
260
+ ### Default rules
261
+
262
+ Tollbooth ships with [rules.json](rules.json) covering:
263
+
264
+ **Deny** — Cloudflare Workers abuse, known bad bots, vulnerability scanners, WordPress probes, dotfile probes, shell probes, path traversal attempts
265
+
266
+ **Allow** — `.well-known/`, `favicon.ico`, `robots.txt`, health checks, search engines, feed readers, monitoring services, link previews, archive.org
267
+
268
+ **Challenge** — AI bots (difficulty 10), headless browsers (6), aggressive scrapers (8), empty user agents (6), generic browsers
269
+
270
+ **Weigh** — curl/wget (+3), missing Accept header (+3), missing Accept-Language (+2), `Connection: close` (+2)
271
+
272
+ ### Custom rules
273
+
274
+ Override by passing a `rules_file` path or constructing a `Policy` directly:
275
+
276
+ ```python
277
+ from tollbooth import Policy, Rule, TollboothWSGI
278
+
279
+ policy = Policy(rules=[
280
+ Rule(name="internal", action="allow",
281
+ remote_addresses=["10.0.0.0/8"]),
282
+ Rule(name="api-bots", action="challenge",
283
+ path="^/api/", difficulty=14),
284
+ Rule(name="default", action="challenge"),
285
+ ])
286
+
287
+ app = TollboothWSGI(your_app, secret="key", policy=policy)
288
+ ```
289
+
290
+ ### Rule templates
291
+
292
+ Block AI scrapers:
293
+
294
+ ```json
295
+ {
296
+ "name": "ai-bots",
297
+ "action": "deny",
298
+ "user_agent": "(?i:GPTBot|ChatGPT|Claude-Web|CCBot|Bytespider)"
299
+ }
300
+ ```
301
+
302
+ Protect API endpoints:
303
+
304
+ ```json
305
+ { "name": "api-protect", "action": "challenge", "path": "^/api/", "difficulty": 14 }
306
+ ```
307
+
308
+ Allowlist internal IPs:
309
+
310
+ ```json
311
+ { "name": "internal", "action": "allow", "remote_addresses": ["10.0.0.0/8", "172.16.0.0/12"] }
312
+ ```
313
+
314
+ Weight scoring for suspicious signals:
315
+
316
+ ```json
317
+ [
318
+ { "name": "no-accept", "action": "weigh", "weight": 3, "headers": { "Accept": "^$" } },
319
+ { "name": "no-lang", "action": "weigh", "weight": 2, "headers": { "Accept-Language": "^$" } },
320
+ { "name": "curl", "action": "weigh", "weight": 3, "user_agent": "(?i:^curl/|^Wget/)" }
321
+ ]
322
+ ```
323
+
324
+ With `challenge_threshold=5`, curl (weight 3) + missing Accept-Language (weight 2) = 5, triggers a challenge.
325
+
326
+ ## Integrations
327
+
328
+ All integrations share the same options via `TollboothBase`:
329
+
330
+ ```python
331
+ from tollbooth.integrations.base import TollboothBase
332
+
333
+ tb = TollboothBase(
334
+ secret="key",
335
+ exclude=[r"^/static/", r"^/health$"], # regex skip list
336
+ json_mode=True, # return JSON instead of HTML challenges
337
+ )
338
+ ```
339
+
340
+ | Integration | Middleware class | Per-route | Exempt decorator |
341
+ | ------------- | --------------------- | -------------------- | ------------------- |
342
+ | **Flask** | `Tollbooth(app)` | `@tb.protect` | `@tb.exempt` |
343
+ | **Django** | `TollboothMiddleware` | `@tollbooth_protect` | `@tollbooth_exempt` |
344
+ | **FastAPI** | `TollboothMiddleware` | `TollboothDep` | `exclude=[...]` |
345
+ | **Starlette** | `TollboothMiddleware` | — | `exclude=[...]` |
346
+ | **Falcon** | `TollboothMiddleware` | `tollbooth_hook` | `exclude=[...]` |
347
+ | **WSGI** | `TollboothWSGI` | — | — |
348
+ | **ASGI** | `TollboothASGI` | — | — |
349
+
350
+ ### JSON mode
351
+
352
+ For API/SPA backends, enable `json_mode=True`. Challenges return JSON instead of HTML:
353
+
354
+ ```json
355
+ {
356
+ "challenge": {
357
+ "id": "abc123",
358
+ "data": "random_hex",
359
+ "difficulty": 10,
360
+ "space_cost": 1024,
361
+ "time_cost": 1,
362
+ "delta": 3,
363
+ "verify_path": "/.tollbooth/verify",
364
+ "redirect": "/api/data"
365
+ }
366
+ }
367
+ ```
368
+
369
+ Solve with the [sha256-balloon](../sha256-balloon/) client library and POST the nonce to the verify endpoint.
370
+
371
+ ## License
372
+
373
+ [MIT](../LICENSE)
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "tollbooth"
3
+ version = "0.1.0"
4
+ description = "Proof-of-work bot challenge middleware"
5
+ requires-python = ">=3.11"
6
+ dependencies = []
7
+
8
+ [project.optional-dependencies]
9
+ test = ["pytest", "pytest-asyncio", "httpx"]
10
+ django = ["django>=4.0"]
11
+ flask = ["flask>=2.0"]
12
+ fastapi = ["fastapi>=0.100"]
13
+ starlette = ["starlette>=0.27"]
14
+ falcon = ["falcon>=3.0"]
15
+
16
+ [build-system]
17
+ requires = ["setuptools>=68"]
18
+ build-backend = "setuptools.build_meta"
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["tollbooth*"]
22
+
23
+ [tool.setuptools.package-data]
24
+ tollbooth = ["rules.json", "challenge.html"]
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]
28
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+