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 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 `/a` can't be replayed on `/b` or reused.
177
- - Set a stable `TOLLBOOTH_SECRET` in production so tokens survive restarts.
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 ({ has(token), add(token, expMs) }, both async) —
41
- * e.g. a Cloudflare KV wrapper; defaults to in-isolate memory.
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
- has: async (k) => { const e = mem.get(k); if (e && e < Date.now()) { mem.delete(k); return false; } return mem.has(k); },
48
- add: async (k, exp) => { mem.set(k, exp); },
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 !== 5) return { ok: false, reason: "malformed token" };
76
- const [chal, expStr, diffStr, res, sig] = parts;
77
- const expected = await hmac(secret, `${chal}.${expStr}.${diffStr}.${res}`);
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
- await used.add(token, Number(expStr));
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
- if (typeof free === "function" && free(request)) return false;
118
- if (typeof charge === "function") return Boolean(charge(request));
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
- if (typeof free === "function" && free(req)) return false;
60
- if (typeof charge === "function") return Boolean(charge(req));
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
- (resourceBaseUrl ? resourceBaseUrl.replace(/\/$/, "") : "") + (req.originalUrl || req.url || "/");
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
- return Promise.resolve(verifyX402(req, { price, network, asset, payTo, resource }))
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 a request to `upstream`, streaming-safe-ish. */
112
- export function createProxy(upstream) {
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
- const target = new URL(req.originalUrl || req.url, upstream);
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
- const lk = k.toLowerCase();
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
- res.send(Buffer.from(await up.arrayBuffer()));
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.2",
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 !== 5) return { ok: false, reason: "malformed token" };
63
- const [chal, expStr, diffStr, res, sig] = parts;
64
- const expected = sign(`${chal}.${expStr}.${diffStr}.${res}`);
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
- has: async (k) => (await kv.get(k)) != null,
17
- add: async (k, expMs) => kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) }),
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
- return fetch(new Request(target.toString(), request));
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
  };