agent402-tollbooth 0.1.2 → 0.1.3

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
@@ -43,9 +43,17 @@ function constEq(a, b) {
43
43
  export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000, store } = {}) {
44
44
  if (!secret) throw new Error("createEdgePow requires a stable `secret` string");
45
45
  const mem = new Map();
46
+ const MAX = 50_000; // bound memory: edge runtimes have no timer to sweep, so prune on write
46
47
  const used = store || {
47
48
  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
+ add: async (k, exp) => {
50
+ if (mem.size >= MAX) {
51
+ const now = Date.now();
52
+ for (const [kk, ee] of mem) if (ee < now) mem.delete(kk); // drop expired first
53
+ if (mem.size >= MAX) mem.delete(mem.keys().next().value); // hard cap: evict oldest
54
+ }
55
+ mem.set(k, exp);
56
+ },
49
57
  };
50
58
 
51
59
  async function challenge(resource) {
@@ -72,9 +80,11 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
72
80
  const token = headerValue.slice(0, cut);
73
81
  const nonce = headerValue.slice(cut + 1);
74
82
  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}`);
83
+ if (parts.length < 5) return { ok: false, reason: "malformed token" };
84
+ const sig = parts.pop();
85
+ const [chal, expStr, diffStr] = parts;
86
+ const res = parts.slice(3).join("."); // resource may itself contain dots (e.g. /post.html?v=1.2)
87
+ const expected = await hmac(secret, parts.join("."));
78
88
  if (!constEq(sig, expected)) return { ok: false, reason: "bad signature" };
79
89
  if (res !== resource) return { ok: false, reason: "wrong resource" };
80
90
  if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
@@ -114,8 +124,10 @@ export function createEdgeTollbooth(config = {}) {
114
124
  const powEngine = pow ? createEdgePow({ secret, difficulty: powDifficulty, store }) : null;
115
125
 
116
126
  const shouldCharge = (request) => {
117
- if (typeof free === "function" && free(request)) return false;
118
- if (typeof charge === "function") return Boolean(charge(request));
127
+ try {
128
+ if (typeof free === "function" && free(request)) return false;
129
+ if (typeof charge === "function") return Boolean(charge(request));
130
+ } catch { return true; /* fail closed: charge on predicate error */ }
119
131
  return isBot(request.headers.get("user-agent") || "");
120
132
  };
121
133
 
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.3",
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
@@ -22,6 +22,11 @@ export default {
22
22
  if (!env.TOLLBOOTH_SECRET) {
23
23
  return new Response("Tollbooth misconfigured: set TOLLBOOTH_SECRET (wrangler secret put TOLLBOOTH_SECRET)", { status: 500 });
24
24
  }
25
+ if (!env.TOLLBOOTH_KV) {
26
+ // No durable store → replay protection is per-isolate only; a solved token
27
+ // can be reused across isolates within its TTL. Bind a KV namespace for prod.
28
+ console.warn("agent402-tollbooth: no TOLLBOOTH_KV bound — proof-of-work replay protection is per-isolate only. Bind a KV namespace for production.");
29
+ }
25
30
  const gate = createEdgeTollbooth({
26
31
  secret: env.TOLLBOOTH_SECRET,
27
32
  price: env.TOLLBOOTH_PRICE || "$0.001",
@@ -42,6 +47,12 @@ export default {
42
47
  target.protocol = origin.protocol;
43
48
  target.hostname = origin.hostname;
44
49
  target.port = origin.port;
45
- return fetch(new Request(target.toString(), request));
50
+ // Strip client-forgeable trust/forwarding headers before forwarding to origin.
51
+ const headers = new Headers(request.headers);
52
+ for (const h of ["x-tollbooth-paid", "x-tollbooth-error", "x-pow-error", "x-forwarded-host", "forwarded"]) headers.delete(h);
53
+ headers.set("x-forwarded-for", request.headers.get("cf-connecting-ip") || "");
54
+ const init = { method: request.method, headers, redirect: "manual" };
55
+ if (request.method !== "GET" && request.method !== "HEAD") init.body = request.body;
56
+ return fetch(target.toString(), init);
46
57
  },
47
58
  };