Skip to content

Commit a43595a

Browse files
author
S. K. Rotwang MMMMMMMMM
committed
Private Watch: security audit + quality wins (matches monorepo §12.41)
Marketing for Private Watch is in the data-api monorepo (docs + landing + stats); this is the code-side mirror. Security audit — ten fixes: - Lock durationDays server-side (fixed 7-day tier, re-POST to renew; previous behaviour gave 30 days for the same $0.10). - IPv6 SSRF guard for ::1, fc00::/7, fe80::/10, IPv4-mapped IPv6 in both dotted-quad and Node-canonicalised hex forms. - New resolveAndValidateWatchRequest does an async DNS A+AAAA lookup and rejects any returned address in a private range — defeats `evil.example.com` → 127.0.0.1 rebinds. Tests inject a stub resolver so the suite stays offline. - Zcash birthdayHeight defaults to NU6 (3,042,000) so the scanner never autoDetects a multi-hour backwards walk. Monero now also honours birthdayHeight (was only Zcash). Bounds [0, 50M]. - HTTPS-only webhook URLs by default (PRIVATE_WATCH_REQUIRE_HTTPS=1). - Reject embedded userinfo in webhook URLs. - deliverWebhook drains the response body via getReader() and aborts the controller at 4 KB to defeat slow-loris megabyte responses. Quick wins: - POST /v1/private/watch/:id/test fires a synthetic signed webhook (event: "synthetic_test") so receivers can verify HMAC handling before relying on real payments. Free, token-auth. - buildWatchSummary exposes last_delivered_event and next_poll_eta_ms. - /v1/stats/overview includes a private_watch block (live counters). 333/333 tests passing (covers new IPv6 forms, DNS rebind rejection, durationDays override rejection, test endpoint with HMAC byte-match assertion, overview block content).
1 parent 47dc75b commit a43595a

8 files changed

Lines changed: 811 additions & 85 deletions

src/config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,25 @@ export const config = Object.freeze({
159159
// for local development. Production deployments leave this off so
160160
// SSRF protection is in force.
161161
privateWatchAllowPrivateWebhooks: asString('PRIVATE_WATCH_ALLOW_PRIVATE_WEBHOOKS', '') === '1',
162+
// Require https:// for webhook URLs. Default ON in production so a
163+
// cleartext token can't be sniffed off the wire. Local dev keeps it
164+
// off to support testing against an http listener.
165+
privateWatchRequireHttps: asString('PRIVATE_WATCH_REQUIRE_HTTPS', '1') === '1',
162166
// How often the poller drives a tick. Each tick polls every active
163167
// watch; NFPT detaches scanners after 5 min idle, so we stay
164168
// comfortably below.
165169
privateWatchPollIntervalSec: asInt('PRIVATE_WATCH_POLL_INTERVAL_SEC', 180),
166170
// HTTP timeout for outbound webhook POSTs. Set short — receivers
167171
// should accept fast and process async.
168172
privateWatchWebhookTimeoutMs: asInt('PRIVATE_WATCH_WEBHOOK_TIMEOUT_MS', 8_000),
173+
// Cap on bytes drained from a webhook receiver's response body.
174+
// We don't use the body — status code is the source of truth — so
175+
// a 4 KB cap defeats slow-loris megabyte responses.
176+
privateWatchResponseMaxBytes: asInt('PRIVATE_WATCH_RESPONSE_MAX_BYTES', 4 * 1024),
177+
// Max active watches per source IP — keeps a single client from
178+
// monopolising poller slots. Each is paywalled at $0.10 already
179+
// so this is mostly a soft DoS guard.
180+
privateWatchMaxPerIp: asInt('PRIVATE_WATCH_MAX_PER_IP', 32),
169181
// x402 price for POST /v1/private/watch. One tier, $0.10 = 7-day
170182
// monitor for one (chain, address, viewKey, webhookUrl). Override
171183
// via env without touching code if we want a promo price.

src/mcp-server.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import {
5454
healthCheck as nfptHealthCheck
5555
} from './private-watch-nfpt.js';
5656
import {
57-
validateWatchRequest,
57+
resolveAndValidateWatchRequest,
5858
buildPrivateInfo,
5959
WATCH_CONSTANTS
6060
} from './private-watch.js';
@@ -374,7 +374,11 @@ export function buildMcpServer(options = {}) {
374374
const nfptHealth = privateWatchReady()
375375
? await nfptHealthCheck(nfptClient).catch((err) => ({ ok: false, reason: err?.message ?? String(err) }))
376376
: { ok: false, reason: 'private watch disabled on this server' };
377-
return asContent(buildPrivateInfo({ x402Cfg, nfptHealth }));
377+
return asContent(buildPrivateInfo({
378+
x402Cfg,
379+
nfptHealth,
380+
requireHttps: config.privateWatchRequireHttps && !config.privateWatchAllowPrivateWebhooks
381+
}));
378382
});
379383

380384
server.registerTool('seneschal_private_watch_create', {
@@ -399,8 +403,9 @@ export function buildMcpServer(options = {}) {
399403
}
400404
let input;
401405
try {
402-
input = validateWatchRequest(params, {
403-
allowPrivateWebhooks: config.privateWatchAllowPrivateWebhooks
406+
input = await resolveAndValidateWatchRequest(params, {
407+
allowPrivateWebhooks: config.privateWatchAllowPrivateWebhooks,
408+
requireHttps: config.privateWatchRequireHttps && !config.privateWatchAllowPrivateWebhooks
404409
});
405410
}
406411
catch (err) {

src/private-watch-poller.js

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ import {
4646

4747
const DEFAULT_WEBHOOK_TIMEOUT_MS = 8_000;
4848
const MAX_LOG_REASON_LEN = 200;
49+
// Max bytes we'll buffer from a misbehaving receiver before
50+
// abandoning the read. 4 KB is enough for any reasonable status
51+
// payload, and tiny enough that a hostile responder can't tie up the
52+
// poller with a slow-loris megabyte.
53+
const DEFAULT_RESPONSE_MAX_BYTES = WATCH_CONSTANTS.WEBHOOK_RESPONSE_MAX_BYTES;
4954

5055
/**
5156
* Run a single poller tick. Returns a summary object so the CLI
@@ -68,6 +73,7 @@ export async function runPollerTick(deps) {
6873
nfptClient,
6974
fetchImpl = globalThis.fetch,
7075
webhookTimeoutMs = DEFAULT_WEBHOOK_TIMEOUT_MS,
76+
responseMaxBytes = DEFAULT_RESPONSE_MAX_BYTES,
7177
logger = console,
7278
now = () => Date.now()
7379
} = deps;
@@ -95,7 +101,8 @@ export async function runPollerTick(deps) {
95101
try {
96102
await pollOne({
97103
row, db, masterKey, nfptClient,
98-
fetchImpl, webhookTimeoutMs, logger, now, summary
104+
fetchImpl, webhookTimeoutMs, responseMaxBytes,
105+
logger, now, summary
99106
});
100107
}
101108
catch (err) {
@@ -114,7 +121,7 @@ export async function runPollerTick(deps) {
114121
return summary;
115122
}
116123

117-
async function pollOne({ row, db, masterKey, nfptClient, fetchImpl, webhookTimeoutMs, logger, now, summary }) {
124+
async function pollOne({ row, db, masterKey, nfptClient, fetchImpl, webhookTimeoutMs, responseMaxBytes, logger, now, summary }) {
118125
const viewKey = decryptViewKey(row.view_key_ct, masterKey);
119126
let jobId = row.nfpt_job_id;
120127
let jobToken = row.nfpt_job_token;
@@ -183,13 +190,20 @@ async function pollOne({ row, db, masterKey, nfptClient, fetchImpl, webhookTimeo
183190
secret: row.webhook_secret,
184191
watchId: row.id,
185192
fetchImpl,
186-
timeoutMs: webhookTimeoutMs
193+
timeoutMs: webhookTimeoutMs,
194+
responseMaxBytes
187195
});
188196
if (result.ok) {
189197
summary.webhooks_delivered += 1;
198+
const eventLabel = diff.first_complete
199+
? 'scan_complete'
200+
: diff.balance_changed
201+
? 'balance_change'
202+
: 'status_change';
190203
updateWatchState(db, row.id, {
191204
last_delivered_balance: knownJson,
192205
last_delivered_at_ms: nowMs,
206+
last_delivered_event: eventLabel,
193207
delivery_attempts: 0,
194208
delivery_count: (row.delivery_count ?? 0) + 1,
195209
last_delivery_error: null
@@ -229,10 +243,16 @@ async function startJobForRow({ row, viewKey, nfptClient }) {
229243
fromHeight: row.birthday_height ?? undefined
230244
});
231245
}
246+
// NEVER autoDetect for Zcash — it walks the chain backwards from
247+
// the tip and can run for hours, holding an NFPT scanner slot for
248+
// a single $0.10 watch. The validator already defaults missing
249+
// birthdayHeight to NU6 (post-April-2024), which covers virtually
250+
// all live wallets. Older wallets must pass an explicit
251+
// birthdayHeight.
232252
return startOrchardJob(nfptClient, {
233253
ufvk: viewKey,
234-
birthdayHeight: row.birthday_height ?? undefined,
235-
autoDetect: row.birthday_height ? false : true
254+
birthdayHeight: row.birthday_height ?? WATCH_CONSTANTS.ZCASH_NU6_HEIGHT,
255+
autoDetect: false
236256
});
237257
}
238258

@@ -246,7 +266,8 @@ async function startJobForRow({ row, viewKey, nfptClient }) {
246266
export async function deliverWebhook({
247267
url, body, secret, watchId,
248268
fetchImpl = globalThis.fetch,
249-
timeoutMs = DEFAULT_WEBHOOK_TIMEOUT_MS
269+
timeoutMs = DEFAULT_WEBHOOK_TIMEOUT_MS,
270+
responseMaxBytes = DEFAULT_RESPONSE_MAX_BYTES
250271
}) {
251272
if (typeof url !== 'string') throw new TypeError('deliverWebhook: url is required');
252273
if (typeof body !== 'string') throw new TypeError('deliverWebhook: body must be a string');
@@ -267,6 +288,11 @@ export async function deliverWebhook({
267288
},
268289
body
269290
});
291+
// Drain a bounded number of bytes from the response so a
292+
// receiver returning a multi-megabyte body can't slow-loris
293+
// us. We don't actually use the contents — the status code is
294+
// the source of truth.
295+
await drainBodyBounded(res, responseMaxBytes, ac);
270296
const ok = res.status >= 200 && res.status < 300;
271297
return { ok, status: res.status, error: ok ? null : `non-2xx HTTP ${res.status}` };
272298
}
@@ -282,6 +308,27 @@ export async function deliverWebhook({
282308
}
283309
}
284310

311+
async function drainBodyBounded(res, maxBytes, ac) {
312+
const reader = res?.body?.getReader?.();
313+
if (!reader) return; // node-fetch shims without streaming bodies
314+
let total = 0;
315+
try {
316+
while (true) {
317+
const { done, value } = await reader.read();
318+
if (done) return;
319+
if (value) total += value.byteLength;
320+
if (total > maxBytes) {
321+
try { ac.abort(new Error(`webhook response exceeded ${maxBytes} bytes`)); }
322+
catch { /* swallow */ }
323+
try { await reader.cancel(); }
324+
catch { /* swallow */ }
325+
return;
326+
}
327+
}
328+
}
329+
catch { /* aborted or socket gone — we don't care about the body */ }
330+
}
331+
285332
function safeJson(s) {
286333
try { return JSON.parse(s); }
287334
catch { return null; }

src/private-watch-store.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS private_watches (
5252
delivery_attempts INTEGER DEFAULT 0,
5353
delivery_count INTEGER DEFAULT 0,
5454
last_delivery_error TEXT,
55+
last_delivered_event TEXT,
5556
dead INTEGER DEFAULT 0,
5657
cancelled INTEGER DEFAULT 0
5758
);
@@ -65,6 +66,11 @@ CREATE INDEX IF NOT EXISTS idx_watch_poll
6566
* Open (or create) the watch DB. Pass a `:memory:` path in tests.
6667
* The parent directory is created if absent so deployments don't
6768
* have to pre-mkdir the state dir.
69+
*
70+
* Idempotent schema migration: `last_delivered_event` was added in a
71+
* later revision. We ALTER TABLE … ADD COLUMN if the table already
72+
* exists without it, ignoring "duplicate column" errors so re-opens
73+
* are a no-op.
6874
*/
6975
export function openWatchDb(path) {
7076
if (typeof path !== 'string' || path.length === 0) {
@@ -79,9 +85,17 @@ export function openWatchDb(path) {
7985
db.pragma('synchronous = NORMAL');
8086
db.pragma('foreign_keys = ON');
8187
db.exec(WATCH_DDL);
88+
addColumnIfMissing(db, 'private_watches', 'last_delivered_event', 'TEXT');
8289
return db;
8390
}
8491

92+
function addColumnIfMissing(db, table, col, type) {
93+
try { db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`).run(); }
94+
catch (err) {
95+
if (!/duplicate column/i.test(err?.message ?? '')) throw err;
96+
}
97+
}
98+
8599
function sha256(bytes) {
86100
return createHash('sha256').update(bytes).digest('hex');
87101
}
@@ -227,6 +241,7 @@ export function updateWatchState(db, id, patch) {
227241
'last_delivered_balance',
228242
'last_polled_at_ms',
229243
'last_delivered_at_ms',
244+
'last_delivered_event',
230245
'delivery_attempts',
231246
'delivery_count',
232247
'last_delivery_error',

0 commit comments

Comments
 (0)