agent402-tollbooth 0.1.2 → 0.1.4
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.
- package/README.md +18 -2
- package/edge.js +30 -11
- package/index.js +45 -14
- package/package.json +1 -1
- package/pow.js +5 -3
- package/worker.js +20 -3
package/README.md
CHANGED
|
@@ -170,9 +170,25 @@ Amazonbot, …). Classic search indexers (Googlebot, Bingbot) are intentionally
|
|
|
170
170
|
or take full control with a `charge(req)` predicate (e.g. charge anything
|
|
171
171
|
without a browser-like `Accept` header, or charge everyone on `/api/*`).
|
|
172
172
|
|
|
173
|
+
## Production checklist (read this)
|
|
174
|
+
|
|
175
|
+
- **Set a stable `TOLLBOOTH_SECRET`.** Required for any multi-process/clustered
|
|
176
|
+
Node deploy and for all edge deploys — without it, proof-of-work tokens use a
|
|
177
|
+
random per-process secret and are rejected across restarts/workers/isolates.
|
|
178
|
+
- **For serverless/edge, supply a durable replay `store`** (e.g. bind a Cloudflare
|
|
179
|
+
KV namespace as `TOLLBOOTH_KV`). The in-memory default is per-isolate, so a
|
|
180
|
+
solved token could be reused across isolates within its TTL. The Worker entry
|
|
181
|
+
warns when no KV is bound.
|
|
182
|
+
- **The reverse proxy pins the host** to your configured upstream (a client can't
|
|
183
|
+
redirect it elsewhere) and **strips client-forged trust/forwarding headers**
|
|
184
|
+
(`X-Tollbooth-Paid`, `X-Forwarded-Host`, etc.) before forwarding.
|
|
185
|
+
- **UA matching is the default, not a security boundary** — a bot can forge a
|
|
186
|
+
human UA to get the *same free access a human gets* (it gains nothing more). If
|
|
187
|
+
you need to charge everyone, pass `charge: () => true`.
|
|
188
|
+
|
|
173
189
|
## Notes
|
|
174
190
|
|
|
175
191
|
- Proof-of-work tokens are HMAC-signed, expiry-checked, single-use, and bound to
|
|
176
|
-
the exact resource — a solution for
|
|
177
|
-
|
|
192
|
+
the exact resource (path + query, dots and all) — a solution for one URL can't
|
|
193
|
+
be replayed or reused on another.
|
|
178
194
|
- MIT licensed. Part of [Agent402](https://github.com/MikeyPetrillo/Agent402).
|
package/edge.js
CHANGED
|
@@ -37,15 +37,29 @@ function constEq(a, b) {
|
|
|
37
37
|
/**
|
|
38
38
|
* Web Crypto proof-of-work. `secret` is required (edge runtimes can't keep a
|
|
39
39
|
* random per-process secret across invocations). `store` is an optional
|
|
40
|
-
* single-use replay backend
|
|
41
|
-
*
|
|
40
|
+
* single-use replay backend: { claim(token, expMs) => bool|Promise<bool> } that
|
|
41
|
+
* atomically returns true only the first time a token is seen — e.g. a Cloudflare
|
|
42
|
+
* KV wrapper; defaults to in-isolate memory.
|
|
42
43
|
*/
|
|
43
44
|
export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000, store } = {}) {
|
|
44
45
|
if (!secret) throw new Error("createEdgePow requires a stable `secret` string");
|
|
45
46
|
const mem = new Map();
|
|
47
|
+
const MAX = 50_000; // bound memory: edge runtimes have no timer to sweep, so prune on write
|
|
46
48
|
const used = store || {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
// Atomic single-use claim. The body is synchronous (no await), so two
|
|
50
|
+
// concurrent claims of the same token cannot both win within an isolate —
|
|
51
|
+
// closing the TOCTOU that a separate has()+add() would open.
|
|
52
|
+
claim: (k, exp) => {
|
|
53
|
+
const e = mem.get(k);
|
|
54
|
+
if (e && e >= Date.now()) return false; // a still-valid entry => already used
|
|
55
|
+
if (mem.size >= MAX) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const [kk, ee] of mem) if (ee < now) mem.delete(kk); // drop expired first
|
|
58
|
+
if (mem.size >= MAX) mem.delete(mem.keys().next().value); // hard cap: evict oldest
|
|
59
|
+
}
|
|
60
|
+
mem.set(k, exp);
|
|
61
|
+
return true;
|
|
62
|
+
},
|
|
49
63
|
};
|
|
50
64
|
|
|
51
65
|
async function challenge(resource) {
|
|
@@ -72,16 +86,19 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
|
|
|
72
86
|
const token = headerValue.slice(0, cut);
|
|
73
87
|
const nonce = headerValue.slice(cut + 1);
|
|
74
88
|
const parts = token.split(".");
|
|
75
|
-
if (parts.length
|
|
76
|
-
const
|
|
77
|
-
const
|
|
89
|
+
if (parts.length < 5) return { ok: false, reason: "malformed token" };
|
|
90
|
+
const sig = parts.pop();
|
|
91
|
+
const [chal, expStr, diffStr] = parts;
|
|
92
|
+
const res = parts.slice(3).join("."); // resource may itself contain dots (e.g. /post.html?v=1.2)
|
|
93
|
+
const expected = await hmac(secret, parts.join("."));
|
|
78
94
|
if (!constEq(sig, expected)) return { ok: false, reason: "bad signature" };
|
|
79
95
|
if (res !== resource) return { ok: false, reason: "wrong resource" };
|
|
80
96
|
if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
|
|
81
|
-
if (await used.has(token)) return { ok: false, reason: "already used" };
|
|
82
97
|
const h = await sha256(`${chal}:${nonce}`);
|
|
83
98
|
if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
|
|
84
|
-
|
|
99
|
+
// Atomically claim single-use only AFTER the work is validated, so an invalid
|
|
100
|
+
// attempt never consumes the token and concurrent dupes can't both pass.
|
|
101
|
+
if (!(await used.claim(token, Number(expStr)))) return { ok: false, reason: "already used" };
|
|
85
102
|
return { ok: true };
|
|
86
103
|
}
|
|
87
104
|
|
|
@@ -114,8 +131,10 @@ export function createEdgeTollbooth(config = {}) {
|
|
|
114
131
|
const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
|
|
115
132
|
|
|
116
133
|
const shouldCharge = (request) => {
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
try {
|
|
135
|
+
if (typeof free === "function" && free(request)) return false;
|
|
136
|
+
if (typeof charge === "function") return Boolean(charge(request));
|
|
137
|
+
} catch { return true; /* fail closed: charge on predicate error */ }
|
|
119
138
|
return isBot(request.headers.get("user-agent") || "");
|
|
120
139
|
};
|
|
121
140
|
|
package/index.js
CHANGED
|
@@ -13,12 +13,22 @@
|
|
|
13
13
|
// accept USDC, set `payTo` and supply `verifyX402` (wire it to the standard
|
|
14
14
|
// x402 server middleware / your facilitator — see README).
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { Readable } from "node:stream";
|
|
16
17
|
import { createPow } from "./pow.js";
|
|
17
18
|
import { makeBotMatcher, AI_BOTS } from "./bots.js";
|
|
18
19
|
|
|
19
20
|
export { AI_BOTS, makeBotMatcher } from "./bots.js";
|
|
20
21
|
export { createPow, leadingZeroBits } from "./pow.js";
|
|
21
22
|
|
|
23
|
+
const VERIFY_TIMEOUT_MS = Number(process.env.TOLLBOOTH_VERIFY_TIMEOUT_MS) || 10_000;
|
|
24
|
+
// Headers a client must never be able to forge through the proxy: the gate's own
|
|
25
|
+
// trust signals and forwarding/hop-by-hop headers.
|
|
26
|
+
const STRIP_INBOUND = new Set([
|
|
27
|
+
"host", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
28
|
+
"te", "trailer", "transfer-encoding", "upgrade", "content-length",
|
|
29
|
+
"x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded",
|
|
30
|
+
]);
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Create the tollbooth Express middleware.
|
|
24
34
|
* @param {object} [config]
|
|
@@ -56,12 +66,20 @@ export function createTollbooth(config = {}) {
|
|
|
56
66
|
const powEngine = pow ? createPow({ difficulty: powDifficulty, secret: powSecret }) : null;
|
|
57
67
|
|
|
58
68
|
const shouldCharge = (req) => {
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
try {
|
|
70
|
+
if (typeof free === "function" && free(req)) return false;
|
|
71
|
+
if (typeof charge === "function") return Boolean(charge(req));
|
|
72
|
+
} catch { return true; /* fail closed: charge on predicate error */ }
|
|
61
73
|
return isBot(req.headers["user-agent"] || "");
|
|
62
74
|
};
|
|
63
|
-
const resourceOf = (req) =>
|
|
64
|
-
|
|
75
|
+
const resourceOf = (req) => {
|
|
76
|
+
// Canonicalize to path+search (matches the edge impl) so the PoW binding is
|
|
77
|
+
// stable and not confusable via a raw/abnormal request target.
|
|
78
|
+
const raw = req.originalUrl || req.url || "/";
|
|
79
|
+
let pathAndSearch = raw;
|
|
80
|
+
try { const u = new URL(raw, "http://internal.invalid"); pathAndSearch = u.pathname + u.search; } catch { /* keep raw */ }
|
|
81
|
+
return (resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : "") + pathAndSearch;
|
|
82
|
+
};
|
|
65
83
|
|
|
66
84
|
function tollbooth(req, res, next) {
|
|
67
85
|
if (!shouldCharge(req)) return next();
|
|
@@ -92,7 +110,9 @@ export function createTollbooth(config = {}) {
|
|
|
92
110
|
// we reuse the standard, audited x402 stack rather than reinvent it.
|
|
93
111
|
const payHeader = req.headers["x-payment"] || req.headers["payment-signature"];
|
|
94
112
|
if (payTo && typeof verifyX402 === "function" && payHeader) {
|
|
95
|
-
|
|
113
|
+
// Bound verification time so a slow/hung verifier can't exhaust resources.
|
|
114
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), VERIFY_TIMEOUT_MS));
|
|
115
|
+
return Promise.race([Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource })), timeout])
|
|
96
116
|
.then((ok) => {
|
|
97
117
|
if (ok) { res.setHeader("X-Tollbooth-Paid", "x402"); return next(); }
|
|
98
118
|
send402();
|
|
@@ -108,19 +128,25 @@ export function createTollbooth(config = {}) {
|
|
|
108
128
|
return tollbooth;
|
|
109
129
|
}
|
|
110
130
|
|
|
111
|
-
/** Minimal reverse proxy: forward
|
|
112
|
-
|
|
131
|
+
/** Minimal reverse proxy: forward to `upstream` with the host PINNED (no SSRF via
|
|
132
|
+
* the request target), client trust/forwarding headers stripped, and the
|
|
133
|
+
* response STREAMED (no unbounded buffering). */
|
|
134
|
+
export function createProxy(upstream, { maxBody = 10 * 1024 * 1024 } = {}) {
|
|
135
|
+
const base = new URL(upstream);
|
|
113
136
|
return async (req, res) => {
|
|
114
137
|
try {
|
|
115
|
-
|
|
138
|
+
// Take ONLY the path+query from the (possibly hostile) target; the
|
|
139
|
+
// authority is always the operator's upstream — protocol-relative or
|
|
140
|
+
// absolute-form targets cannot redirect us to another host.
|
|
141
|
+
const reqUrl = new URL(req.originalUrl || req.url || "/", base);
|
|
142
|
+
const target = new URL(reqUrl.pathname + reqUrl.search, base);
|
|
116
143
|
const headers = {};
|
|
117
144
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
118
|
-
|
|
119
|
-
if (lk === "host" || lk === "connection" || lk === "content-length") continue;
|
|
120
|
-
headers[k] = v;
|
|
145
|
+
if (!STRIP_INBOUND.has(k.toLowerCase())) headers[k] = v;
|
|
121
146
|
}
|
|
147
|
+
headers["x-forwarded-for"] = req.socket?.remoteAddress || ""; // set by us, not the client
|
|
122
148
|
const method = req.method;
|
|
123
|
-
const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req);
|
|
149
|
+
const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req, maxBody);
|
|
124
150
|
const up = await fetch(target, { method, headers, body, redirect: "manual" });
|
|
125
151
|
res.status(up.status);
|
|
126
152
|
up.headers.forEach((val, key) => {
|
|
@@ -129,9 +155,11 @@ export function createProxy(upstream) {
|
|
|
129
155
|
if (["content-encoding", "content-length", "transfer-encoding", "connection"].includes(lk)) return;
|
|
130
156
|
res.setHeader(key, val);
|
|
131
157
|
});
|
|
132
|
-
|
|
158
|
+
if (up.body) Readable.fromWeb(up.body).pipe(res);
|
|
159
|
+
else res.end();
|
|
133
160
|
} catch (e) {
|
|
134
|
-
res.status(502).json({ error: `tollbooth proxy failed: ${e.message}` });
|
|
161
|
+
if (!res.headersSent) res.status(502).json({ error: `tollbooth proxy failed: ${e.message}` });
|
|
162
|
+
else res.end();
|
|
135
163
|
}
|
|
136
164
|
};
|
|
137
165
|
}
|
|
@@ -153,6 +181,9 @@ function readBody(req, cap = 10 * 1024 * 1024) {
|
|
|
153
181
|
async function startCli() {
|
|
154
182
|
const upstream = process.env.TOLLBOOTH_UPSTREAM;
|
|
155
183
|
const port = Number(process.env.PORT) || 4021;
|
|
184
|
+
if (!process.env.TOLLBOOTH_SECRET) {
|
|
185
|
+
console.warn("⚠ TOLLBOOTH_SECRET not set — proof-of-work tokens use a random per-process secret: they won't survive a restart and will be rejected across multiple workers/instances. Set a stable TOLLBOOTH_SECRET in production.");
|
|
186
|
+
}
|
|
156
187
|
const { default: express } = await import("express");
|
|
157
188
|
const app = express();
|
|
158
189
|
const gate = createTollbooth({ resourceBaseUrl: process.env.TOLLBOOTH_RESOURCE_BASE || upstream || "" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-tollbooth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Open-source, self-hostable x402 pay-per-crawl tollbooth: charge AI bots per request (USDC via x402, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy — no Cloudflare, no Stripe, no signup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/pow.js
CHANGED
|
@@ -59,9 +59,11 @@ export function createPow({
|
|
|
59
59
|
const token = headerValue.slice(0, cut);
|
|
60
60
|
const nonce = headerValue.slice(cut + 1);
|
|
61
61
|
const parts = token.split(".");
|
|
62
|
-
if (parts.length
|
|
63
|
-
const
|
|
64
|
-
const
|
|
62
|
+
if (parts.length < 5) return { ok: false, reason: "malformed token" };
|
|
63
|
+
const sig = parts.pop();
|
|
64
|
+
const [chal, expStr, diffStr] = parts;
|
|
65
|
+
const res = parts.slice(3).join("."); // resource may itself contain dots (e.g. /post.html?v=1.2)
|
|
66
|
+
const expected = sign(parts.join("."));
|
|
65
67
|
const a = Buffer.from(sig);
|
|
66
68
|
const b = Buffer.from(expected);
|
|
67
69
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: "bad signature" };
|
package/worker.js
CHANGED
|
@@ -13,8 +13,14 @@
|
|
|
13
13
|
import { createEdgeTollbooth } from "./edge.js";
|
|
14
14
|
|
|
15
15
|
const kvStore = (kv) => ({
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// Best-effort atomic claim. KV has no native compare-and-set, so this is
|
|
17
|
+
// get-then-put (eventually consistent); for strict single-use, back it with a
|
|
18
|
+
// Durable Object. Returns true only the first time a token is seen.
|
|
19
|
+
claim: async (k, expMs) => {
|
|
20
|
+
if ((await kv.get(k)) != null) return false;
|
|
21
|
+
await kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) });
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
18
24
|
});
|
|
19
25
|
|
|
20
26
|
export default {
|
|
@@ -22,6 +28,11 @@ export default {
|
|
|
22
28
|
if (!env.TOLLBOOTH_SECRET) {
|
|
23
29
|
return new Response("Tollbooth misconfigured: set TOLLBOOTH_SECRET (wrangler secret put TOLLBOOTH_SECRET)", { status: 500 });
|
|
24
30
|
}
|
|
31
|
+
if (!env.TOLLBOOTH_KV) {
|
|
32
|
+
// No durable store → replay protection is per-isolate only; a solved token
|
|
33
|
+
// can be reused across isolates within its TTL. Bind a KV namespace for prod.
|
|
34
|
+
console.warn("agent402-tollbooth: no TOLLBOOTH_KV bound — proof-of-work replay protection is per-isolate only. Bind a KV namespace for production.");
|
|
35
|
+
}
|
|
25
36
|
const gate = createEdgeTollbooth({
|
|
26
37
|
secret: env.TOLLBOOTH_SECRET,
|
|
27
38
|
price: env.TOLLBOOTH_PRICE || "$0.001",
|
|
@@ -42,6 +53,12 @@ export default {
|
|
|
42
53
|
target.protocol = origin.protocol;
|
|
43
54
|
target.hostname = origin.hostname;
|
|
44
55
|
target.port = origin.port;
|
|
45
|
-
|
|
56
|
+
// Strip client-forgeable trust/forwarding headers before forwarding to origin.
|
|
57
|
+
const headers = new Headers(request.headers);
|
|
58
|
+
for (const h of ["x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded"]) headers.delete(h);
|
|
59
|
+
headers.set("x-forwarded-for", request.headers.get("cf-connecting-ip") || "");
|
|
60
|
+
const init = { method: request.method, headers, redirect: "manual" };
|
|
61
|
+
if (request.method !== "GET" && request.method !== "HEAD") init.body = request.body;
|
|
62
|
+
return fetch(target.toString(), init);
|
|
46
63
|
},
|
|
47
64
|
};
|