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 +7 -2
- package/edge.js +9 -2
- package/index.js +10 -2
- package/package.json +1 -1
- package/sinks.js +27 -13
- package/worker.js +16 -2
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
|
-
|
|
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({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
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" } });
|