@thecryptodonkey/toll-booth 3.2.1 → 3.2.2
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/dist/adapters/express.js +1 -0
- package/dist/adapters/hono.js +43 -2
- package/dist/adapters/proxy-headers.js +6 -2
- package/dist/backends/nwc.js +3 -0
- package/dist/core/toll-booth.js +12 -1
- package/dist/core/x402-rail.js +3 -2
- package/dist/free-tier.js +11 -7
- package/dist/storage/memory.js +1 -0
- package/dist/storage/sqlite.js +2 -2
- package/package.json +1 -1
package/dist/adapters/express.js
CHANGED
|
@@ -162,6 +162,7 @@ export function createExpressMiddleware(engineOrConfig, upstreamArg) {
|
|
|
162
162
|
}
|
|
163
163
|
catch (err) {
|
|
164
164
|
// Distinguish upstream network errors from programming errors
|
|
165
|
+
setSensitiveHeaders(res);
|
|
165
166
|
if (err instanceof TypeError && (err.message.includes('fetch') || err.message.includes('network'))) {
|
|
166
167
|
res.status(502).json({ error: 'Upstream unavailable' });
|
|
167
168
|
}
|
package/dist/adapters/hono.js
CHANGED
|
@@ -7,9 +7,44 @@ import { handleNwcPay } from '../core/nwc-pay.js';
|
|
|
7
7
|
import { handleCashuRedeem } from '../core/cashu-redeem.js';
|
|
8
8
|
import { applySecurityHeaders, appendVary, parseForwardedIp } from './proxy-headers.js';
|
|
9
9
|
const MAX_BODY_BYTES = 65_536;
|
|
10
|
+
/**
|
|
11
|
+
* Reads the request body as text with a streaming byte limit.
|
|
12
|
+
* Aborts mid-stream when the limit is exceeded, preventing memory
|
|
13
|
+
* exhaustion from large chunked requests without a Content-Length header.
|
|
14
|
+
*/
|
|
15
|
+
async function readBodyTextWithinLimit(c, maxBytes) {
|
|
16
|
+
const body = c.req.raw.body;
|
|
17
|
+
if (!body)
|
|
18
|
+
return '';
|
|
19
|
+
const reader = body.getReader();
|
|
20
|
+
const decoder = new TextDecoder();
|
|
21
|
+
let totalBytes = 0;
|
|
22
|
+
let text = '';
|
|
23
|
+
try {
|
|
24
|
+
while (true) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
if (done)
|
|
27
|
+
break;
|
|
28
|
+
totalBytes += value.byteLength;
|
|
29
|
+
if (totalBytes > maxBytes) {
|
|
30
|
+
await reader.cancel();
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
text += decoder.decode(value, { stream: true });
|
|
34
|
+
}
|
|
35
|
+
return text + decoder.decode();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
reader.releaseLock();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
10
44
|
/**
|
|
11
45
|
* Parses request body as JSON with a size limit.
|
|
12
46
|
* Returns undefined on oversized, empty, or malformed bodies.
|
|
47
|
+
* Uses streaming reads to abort early on large chunked payloads.
|
|
13
48
|
*/
|
|
14
49
|
async function safeParseJson(c, maxBytes = MAX_BODY_BYTES) {
|
|
15
50
|
const contentLength = c.req.header('content-length');
|
|
@@ -19,8 +54,8 @@ async function safeParseJson(c, maxBytes = MAX_BODY_BYTES) {
|
|
|
19
54
|
return undefined;
|
|
20
55
|
}
|
|
21
56
|
try {
|
|
22
|
-
const text = await c
|
|
23
|
-
if (
|
|
57
|
+
const text = await readBodyTextWithinLimit(c, maxBytes);
|
|
58
|
+
if (text === undefined)
|
|
24
59
|
return undefined;
|
|
25
60
|
if (!text.trim())
|
|
26
61
|
return {};
|
|
@@ -44,6 +79,12 @@ async function safeParseJson(c, maxBytes = MAX_BODY_BYTES) {
|
|
|
44
79
|
*/
|
|
45
80
|
export function createHonoTollBooth(config) {
|
|
46
81
|
const { engine } = config;
|
|
82
|
+
// Warn when free-tier is enabled without trustProxy or getClientIp
|
|
83
|
+
if (engine.freeTier && !config.trustProxy && !config.getClientIp) {
|
|
84
|
+
console.error('[toll-booth] WARNING: freeTier enabled without trustProxy in Hono adapter. ' +
|
|
85
|
+
'All clients will share the 0.0.0.0 IP bucket. ' +
|
|
86
|
+
'Set trustProxy: true or provide a getClientIp callback.');
|
|
87
|
+
}
|
|
47
88
|
const authMiddleware = async (c, next) => {
|
|
48
89
|
const req = c.req.raw;
|
|
49
90
|
const ip = config.getClientIp?.(c)
|
|
@@ -39,6 +39,7 @@ export function applySecurityHeaders(headers) {
|
|
|
39
39
|
headers.set('X-Frame-Options', 'DENY');
|
|
40
40
|
headers.set('Referrer-Policy', 'no-referrer');
|
|
41
41
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
42
|
+
headers.set('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; form-action 'none'; frame-ancestors 'none'");
|
|
42
43
|
return headers;
|
|
43
44
|
}
|
|
44
45
|
export function appendVary(headers, value) {
|
|
@@ -58,11 +59,14 @@ export function appendVary(headers, value) {
|
|
|
58
59
|
* via crafted X-Forwarded-For headers.
|
|
59
60
|
*/
|
|
60
61
|
const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
61
|
-
const IPV6_RE = /^[0-9a-fA-F:]
|
|
62
|
+
const IPV6_RE = /^[0-9a-fA-F:]{2,45}$/;
|
|
62
63
|
export function isPlausibleIp(value) {
|
|
63
64
|
if (!value || value.length > 45)
|
|
64
65
|
return false;
|
|
65
|
-
|
|
66
|
+
if (IPV4_RE.test(value))
|
|
67
|
+
return true;
|
|
68
|
+
// IPv6 must contain at least one colon
|
|
69
|
+
return IPV6_RE.test(value) && value.includes(':');
|
|
66
70
|
}
|
|
67
71
|
/**
|
|
68
72
|
* Extracts and validates the client IP from an X-Forwarded-For header value.
|
package/dist/backends/nwc.js
CHANGED
|
@@ -46,6 +46,9 @@ export function nwcBackend(config) {
|
|
|
46
46
|
return { bolt11: tx.invoice, paymentHash: tx.payment_hash };
|
|
47
47
|
},
|
|
48
48
|
async checkInvoice(paymentHash) {
|
|
49
|
+
if (!/^[0-9a-f]{64}$/.test(paymentHash)) {
|
|
50
|
+
return { paid: false };
|
|
51
|
+
}
|
|
49
52
|
const nwc = await getClient();
|
|
50
53
|
try {
|
|
51
54
|
const tx = await nwc.lookupInvoice({ payment_hash: paymentHash });
|
package/dist/core/toll-booth.js
CHANGED
|
@@ -160,13 +160,24 @@ export function createTollBooth(config) {
|
|
|
160
160
|
}
|
|
161
161
|
// Track estimated cost with currency for reconciliation
|
|
162
162
|
if (result.paymentId) {
|
|
163
|
-
// Evict stale entries
|
|
163
|
+
// Evict stale entries; if still at cap, drop oldest entries
|
|
164
164
|
if (estimatedCosts.size >= MAX_ESTIMATED_COSTS) {
|
|
165
165
|
const now = Date.now();
|
|
166
166
|
for (const [key, entry] of estimatedCosts) {
|
|
167
167
|
if (now - entry.ts > MAX_AGE_MS)
|
|
168
168
|
estimatedCosts.delete(key);
|
|
169
169
|
}
|
|
170
|
+
// Force-evict oldest entries if still at capacity
|
|
171
|
+
if (estimatedCosts.size >= MAX_ESTIMATED_COSTS) {
|
|
172
|
+
const overflow = estimatedCosts.size - MAX_ESTIMATED_COSTS + 1;
|
|
173
|
+
let evicted = 0;
|
|
174
|
+
for (const key of estimatedCosts.keys()) {
|
|
175
|
+
if (evicted >= overflow)
|
|
176
|
+
break;
|
|
177
|
+
estimatedCosts.delete(key);
|
|
178
|
+
evicted++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
170
181
|
}
|
|
171
182
|
estimatedCosts.set(result.paymentId, { cost, ts: Date.now(), currency: result.currency });
|
|
172
183
|
}
|
package/dist/core/x402-rail.js
CHANGED
|
@@ -50,9 +50,10 @@ export function createX402Rail(config) {
|
|
|
50
50
|
if (!result.valid) {
|
|
51
51
|
return { authenticated: false, paymentId: result.txHash || '', mode: 'per-request', currency: 'usd' };
|
|
52
52
|
}
|
|
53
|
-
// Credit mode: persist balance to storage (mirrors L402 rail's settleWithCredit)
|
|
53
|
+
// Credit mode: persist balance to storage (mirrors L402 rail's settleWithCredit).
|
|
54
|
+
// Use the txHash as the settlement secret since x402 has no preimage equivalent.
|
|
54
55
|
if (creditMode && storage && !storage.isSettled(result.txHash)) {
|
|
55
|
-
storage.settleWithCredit(result.txHash, result.amount,
|
|
56
|
+
storage.settleWithCredit(result.txHash, result.amount, result.txHash, 'usd');
|
|
56
57
|
}
|
|
57
58
|
const creditBalance = creditMode && storage
|
|
58
59
|
? storage.balance(result.txHash, 'usd')
|
package/dist/free-tier.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// src/free-tier.ts
|
|
2
2
|
/** Maximum number of distinct IPs tracked before new IPs are denied. */
|
|
3
3
|
const MAX_TRACKED_IPS = 100_000;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Validates that a string is a plausible IP address or IP hash.
|
|
6
|
+
* More permissive than the proxy-headers version because the toll-booth
|
|
7
|
+
* engine passes hashIp() output (32-char hex) to freeTier.check().
|
|
8
|
+
*/
|
|
5
9
|
const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
6
|
-
const
|
|
7
|
-
function
|
|
8
|
-
if (!value || value.length >
|
|
10
|
+
const HEX_OR_IPV6_RE = /^[0-9a-fA-F:]{2,64}$/;
|
|
11
|
+
function isPlausibleIpOrHash(value) {
|
|
12
|
+
if (!value || value.length > 64)
|
|
9
13
|
return false;
|
|
10
|
-
return IPV4_RE.test(value) ||
|
|
14
|
+
return IPV4_RE.test(value) || HEX_OR_IPV6_RE.test(value);
|
|
11
15
|
}
|
|
12
16
|
export class FreeTier {
|
|
13
17
|
requestsPerDay;
|
|
@@ -25,7 +29,7 @@ export class FreeTier {
|
|
|
25
29
|
}
|
|
26
30
|
check(ip, _cost) {
|
|
27
31
|
// Reject non-IP strings to prevent arbitrary values filling the tracking map
|
|
28
|
-
if (!
|
|
32
|
+
if (!isPlausibleIpOrHash(ip)) {
|
|
29
33
|
return { allowed: false, remaining: 0 };
|
|
30
34
|
}
|
|
31
35
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
@@ -66,7 +70,7 @@ export class CreditFreeTier {
|
|
|
66
70
|
}
|
|
67
71
|
check(ip, cost) {
|
|
68
72
|
// Reject non-IP strings to prevent arbitrary values filling the tracking map
|
|
69
|
-
if (!
|
|
73
|
+
if (!isPlausibleIpOrHash(ip)) {
|
|
70
74
|
return { allowed: false, remaining: 0 };
|
|
71
75
|
}
|
|
72
76
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
package/dist/storage/memory.js
CHANGED
package/dist/storage/sqlite.js
CHANGED
|
@@ -364,14 +364,14 @@ export function sqliteStorage(config) {
|
|
|
364
364
|
const result = stmtPruneInvoices.run(maxAgeSecs);
|
|
365
365
|
return result.changes;
|
|
366
366
|
},
|
|
367
|
-
pruneStaleRecords(maxAgeMs) {
|
|
367
|
+
pruneStaleRecords: db.transaction((maxAgeMs) => {
|
|
368
368
|
const maxAgeSecs = Math.floor(maxAgeMs / 1000);
|
|
369
369
|
let total = 0;
|
|
370
370
|
total += stmtPruneZeroCredits.run(maxAgeSecs).changes;
|
|
371
371
|
total += stmtPruneSettlements.run(maxAgeSecs).changes;
|
|
372
372
|
total += stmtPruneClaims.run(maxAgeSecs).changes;
|
|
373
373
|
return total;
|
|
374
|
-
},
|
|
374
|
+
}),
|
|
375
375
|
close() {
|
|
376
376
|
db.close();
|
|
377
377
|
},
|
package/package.json
CHANGED