agent402-tollbooth 0.3.0 → 0.3.1

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/dashboard.js CHANGED
@@ -43,10 +43,15 @@ const cards=[
43
43
  async function tick(){
44
44
  try{
45
45
  const s=await (await fetch("/__tollbooth/stats",{cache:"no-store"})).json();
46
+ // Counters are always numbers — coerce, never trust raw JSON from the
47
+ // configured stats endpoint (a compromised collector with httpStatsSink
48
+ // could otherwise inject HTML through this innerHTML sink).
46
49
  document.getElementById("grid").innerHTML=cards.map(function(c){
47
50
  var k=c[0],label=c[1],acc=c[2];
48
- if(k==="wouldCharge" && !(s.wouldCharge>0) && !s.observe) return "";
49
- return '<div class="card"><div class="k">'+label+'</div><div class="v '+(acc||"")+'">'+(s[k]??0)+'</div></div>';
51
+ if(k==="wouldCharge" && !(Number(s.wouldCharge)>0) && !s.observe) return "";
52
+ var v=Number(s[k]); if(!Number.isFinite(v)) v=0;
53
+ var lbl=String(label).replace(/[&<>"']/g,function(ch){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[ch];});
54
+ return '<div class="card"><div class="k">'+lbl+'</div><div class="v '+(acc||"")+'">'+v+'</div></div>';
50
55
  }).join("");
51
56
  var botish=(s.charged||0)+(s.wouldCharge||0);
52
57
  var pct=s.requests?Math.round((botish/s.requests)*100):0;
package/edge.js CHANGED
@@ -146,7 +146,12 @@ export function createEdgeTollbooth(config = {}) {
146
146
  const mem = memorySink();
147
147
  const sink = statsSink || mem;
148
148
  const writeThrough = statsSink && statsSink !== mem;
149
- const incr = (k, n = 1) => { mem.incr(k, n); if (writeThrough) sink.incr(k, n); };
149
+ // Stats must never break a request path — a buggy custom sink can throw
150
+ // synchronously; we shrug and continue.
151
+ const incr = (k, n = 1) => {
152
+ try { mem.incr(k, n); } catch { /* ignore */ }
153
+ if (writeThrough) { try { sink.incr(k, n); } catch { /* ignore */ } }
154
+ };
150
155
 
151
156
  const looksHuman = (request) => {
152
157
  const ua = request.headers.get("user-agent") || "";
@@ -195,6 +200,8 @@ export function createEdgeTollbooth(config = {}) {
195
200
  gate.observe = observe;
196
201
  gate.stats = () => ({ ...mem.snapshot(), observe });
197
202
  gate.snapshot = async () => ({ ...(await sink.snapshot()), observe });
198
- gate.flush = () => Promise.resolve(sink.flush && sink.flush());
203
+ // flush() is typically wired to ctx.waitUntil — swallow errors so a sink
204
+ // outage can't surface as an unhandled rejection after the response.
205
+ gate.flush = async () => { try { if (sink.flush) await sink.flush(); } catch { /* ignore */ } };
199
206
  return gate;
200
207
  }
package/index.js CHANGED
@@ -96,7 +96,12 @@ export function createTollbooth(config = {}) {
96
96
  const mem = memorySink();
97
97
  const sink = statsSink || mem;
98
98
  const writeThrough = statsSink && statsSink !== mem;
99
- const incr = (k, n = 1) => { mem.incr(k, n); if (writeThrough) sink.incr(k, n); };
99
+ // Never let a buggy custom sink throw inside the request path — stats are
100
+ // non-critical and must not be able to break a payment decision.
101
+ const incr = (k, n = 1) => {
102
+ try { mem.incr(k, n); } catch { /* ignore */ }
103
+ if (writeThrough) { try { sink.incr(k, n); } catch { /* ignore */ } }
104
+ };
100
105
 
101
106
  const looksHuman = (req) => {
102
107
  const ua = req.headers["user-agent"] || "";
@@ -190,7 +195,10 @@ export function createTollbooth(config = {}) {
190
195
  // .stats() is sync (in-process mirror). .snapshot() is async (durable sink).
191
196
  tollbooth.stats = () => ({ ...mem.snapshot(), difficultyNow: difficultyNow(), observe });
192
197
  tollbooth.snapshot = async () => ({ ...(await sink.snapshot()), difficultyNow: difficultyNow(), observe });
193
- tollbooth.flush = () => Promise.resolve(sink.flush && sink.flush());
198
+ // Swallow flush errors — flush() is typically wired to ctx.waitUntil on the
199
+ // edge; an unhandled rejection there pollutes logs without affecting the
200
+ // already-sent response.
201
+ tollbooth.flush = async () => { try { if (sink.flush) await sink.flush(); } catch { /* ignore */ } };
194
202
  return tollbooth;
195
203
  }
196
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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/sinks.js CHANGED
@@ -16,14 +16,24 @@
16
16
  // Want strong consistency on Cloudflare? Back it with a Durable Object that
17
17
  // implements the same { incr, snapshot } interface and pass it as `statsSink`.
18
18
 
19
- const ZERO = () => ({
20
- requests: 0,
21
- freeAllowed: 0,
22
- wouldCharge: 0,
23
- charged: 0,
24
- powSolved: 0,
25
- x402Paid: 0,
26
- });
19
+ const KNOWN_FIELDS = ["requests", "freeAllowed", "wouldCharge", "charged", "powSolved", "x402Paid"];
20
+ const ZERO = () => Object.fromEntries(KNOWN_FIELDS.map((k) => [k, 0]));
21
+
22
+ // Filter an untrusted JSON blob (KV value or HTTP collector response) to only
23
+ // the known numeric counters. Prevents a compromised store from injecting
24
+ // arbitrary keys or non-numeric values that downstream renderers (e.g.
25
+ // dashboard.js) might treat as HTML. Defense in depth — the dashboard also
26
+ // coerces, but trim at the boundary.
27
+ const sanitize = (obj) => {
28
+ const out = ZERO();
29
+ if (!obj || typeof obj !== "object") return out;
30
+ for (const k of KNOWN_FIELDS) {
31
+ const v = Number(obj[k]);
32
+ if (Number.isFinite(v) && v >= 0) out[k] = v;
33
+ }
34
+ if (typeof obj.since === "string" && obj.since.length < 64) out.since = obj.since;
35
+ return out;
36
+ };
27
37
 
28
38
  /** In-process counter. Synchronous; loses state on restart. */
29
39
  export function memorySink() {
@@ -77,9 +87,11 @@ export function kvStatsSink(kv, { bucket = "default", ttlSeconds = 60 * 60 * 24
77
87
  if (!raw) continue;
78
88
  let obj; try { obj = JSON.parse(raw); } catch { continue; }
79
89
  if (typeof obj._ts === "number" && obj._ts < oldest) oldest = obj._ts;
80
- for (const [k, n] of Object.entries(obj)) {
81
- if (k === "_ts") continue;
82
- if (typeof n === "number") result[k] = (result[k] || 0) + n;
90
+ // Whitelist + numeric coerce: a misconfigured (or compromised) KV
91
+ // entry can't smuggle arbitrary keys or strings into the snapshot.
92
+ for (const k of KNOWN_FIELDS) {
93
+ const v = Number(obj[k]);
94
+ if (Number.isFinite(v) && v >= 0) result[k] = (result[k] || 0) + v;
83
95
  }
84
96
  }
85
97
  cursor = page.cursor;
@@ -140,8 +152,10 @@ export function httpStatsSink(url, { token, batchMs = 2000, fetchImpl } = {}) {
140
152
  signal: AbortSignal.timeout(5000),
141
153
  });
142
154
  if (!r.ok) return ZERO();
143
- const j = await r.json();
144
- return { ...ZERO(), ...j };
155
+ // Sanitize at the trust boundary — a compromised or misconfigured
156
+ // collector cannot inject non-numeric values or extra keys that the
157
+ // dashboard would render as HTML.
158
+ return sanitize(await r.json());
145
159
  } catch { return ZERO(); }
146
160
  },
147
161
  };
package/worker.js CHANGED
@@ -16,6 +16,17 @@
16
16
  import { createEdgeTollbooth, kvStatsSink } from "./edge.js";
17
17
  import { dashboardHtml } from "./dashboard.js";
18
18
 
19
+ // Constant-time string compare — Cloudflare Workers don't ship node:crypto's
20
+ // timingSafeEqual. Short-circuiting `===` on a secret token leaks length and
21
+ // prefix bits to a sufficiently patient attacker; this doesn't.
22
+ function constEq(a, b) {
23
+ a = String(a || ""); b = String(b || "");
24
+ if (a.length !== b.length) return false;
25
+ let r = 0;
26
+ for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
27
+ return r === 0;
28
+ }
29
+
19
30
  const kvStore = (kv) => ({
20
31
  // Best-effort atomic claim. KV has no native compare-and-set, so this is
21
32
  // get-then-put (eventually consistent); for strict single-use, back it with a
@@ -61,10 +72,13 @@ export default {
61
72
  return new Response(dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } });
62
73
  }
63
74
  if (u.pathname === "/__tollbooth/stats") {
64
- // Optional bearer-token gate — share with your monitoring caller.
75
+ // Optional bearer-token gate — share with your monitoring caller. We
76
+ // recommend setting this in any prod deploy: without it, anyone on the
77
+ // internet can read aggregate counts (no per-request data, but still
78
+ // potentially sensitive competitive info).
65
79
  if (env.TOLLBOOTH_STATS_TOKEN) {
66
80
  const got = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
67
- if (got !== env.TOLLBOOTH_STATS_TOKEN) return new Response("unauthorized", { status: 401 });
81
+ if (!constEq(got, env.TOLLBOOTH_STATS_TOKEN)) return new Response("unauthorized", { status: 401 });
68
82
  }
69
83
  const snap = await gate.snapshot();
70
84
  return new Response(JSON.stringify(snap), { headers: { "content-type": "application/json" } });