diff --git a/CHANGELOG.md b/CHANGELOG.md index bf45761..1be559e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.16.0] - 2026-09-08 + +### Added +- The rate limiter logs one counter line per window when it refuses requests, naming the count and the knob to raise, so an operator can see a 429 happening. + ## [0.15.4] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index 6b0c29e..9baf3a2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # XChain Platform Encoder

- Version - Tests + Version + Tests Node License

@@ -147,9 +147,8 @@ parity check CI runs across the vendored copies. These four names configure the shim itself. The fleet deploy path carries them into the container: `xchain-node` forwards any of them set in the module config store or in the deploy host's environment (`ModuleService.resolveObservabilityEnv`), -and the validator compose files under `claude/deploy/testnet-validators/` name -them outright. Nothing is fabricated when neither source sets one, so these -defaults hold on an unconfigured box: +and the validator compose files name them outright. Nothing is fabricated when +neither source sets one, so these defaults hold on an unconfigured box: | Variable | Default | Effect | |---|---|---| @@ -166,20 +165,20 @@ defaults hold on an unconfigured box: | `npm run build` | Production browser bundle (minified) -> `dist/xchain_encoder.min.js` | | `npm run build:dev` | Development browser bundle (unminified) | | `npm run smoke-test` | Smoke tests (~52 tests, <1s) | -| `npm run test:unit` | Unit tests (759 tests) | -| `npm run test:integration` | Integration tests (112 tests) | +| `npm run test:unit` | Unit tests (798 tests) | +| `npm run test:integration` | Integration tests (115 tests) | | `npm run test:boundary` | Boundary condition tests (~101 tests) | -| `npm run test:security` | Security tests (57 tests) | +| `npm run test:security` | Security tests (63 tests) | | `npm run test:fuzz` | Property-based fuzz tests (6 suites, 6 tests) | -| `npm run test:chaos` | Chaos engineering tests (63 tests) | +| `npm run test:chaos` | Chaos engineering tests (65 tests) | | `npm run test:e2e` | End-to-end tests (~158 tests) | -| `npm run test:regression` | Regression tests (290 tests) | +| `npm run test:regression` | Regression tests (299 tests) | | `npm run mutate` | Full mutation testing via StrykerJS | | `npm run mutate:quick` | Quick mutation check (XChainEncoder.js only) | | `npm run bench` | Performance benchmarks | | `npm run bench:full` | Extended benchmarks with JSON output | | `npm run bench:soak` | Soak test (sustained load) | -| `npm test` | Unit tests (hermetic, no external services, 759 tests) | +| `npm test` | Unit tests (hermetic, no external services, 798 tests) | | `npm run test:regtest` | Regtest integration tests (requires local bitcoind) | ## Test Suite diff --git a/package-lock.json b/package-lock.json index 9d32a05..acfecf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-encoder", - "version": "0.15.4", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.15.4", + "version": "0.16.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index f7d24b0..044c046 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-encoder", "description": "xchain-encoder encodes XChain Platform ACTION commands into blockchain transactions.", - "version": "0.15.4", + "version": "0.16.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", diff --git a/src/api.js b/src/api.js index 27f11b9..e2c870a 100644 --- a/src/api.js +++ b/src/api.js @@ -39,6 +39,7 @@ const helmet = require('helmet'); const cors = require('cors'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); +const { limitedHandler } = require('./rateLimitLog.js') const XChainEncoder = require('./XChainEncoder'); const jsonRouter = require('express-json-rpc-router') const concurrencyGate = require('./concurrencyGate.js') @@ -180,12 +181,23 @@ if (API_KEY) { }) } +const ENCODER_RATE_LIMIT_WINDOW_MS = 60 * 1000 +const ENCODER_RATE_LIMIT_RPM = parseInt(process.env.ENCODER_RATE_LIMIT_RPM, 10) || 60 const limiter = rateLimit({ - windowMs: 60 * 1000, - limit: parseInt(process.env.ENCODER_RATE_LIMIT_RPM, 10) || 60, + windowMs: ENCODER_RATE_LIMIT_WINDOW_MS, + limit: ENCODER_RATE_LIMIT_RPM, standardHeaders: true, legacyHeaders: false, - message: { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } } + // Counts refusals instead of logging one line per request; see + // src/rateLimitLog.js. + handler: limitedHandler({ + service: 'Encoder', + name: 'app-wide', + envVar: 'ENCODER_RATE_LIMIT_RPM', + limit: ENCODER_RATE_LIMIT_RPM, + windowMs: ENCODER_RATE_LIMIT_WINDOW_MS, + message: { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } } + }) }) app.use(limiter) diff --git a/src/observability/logShipper.js b/src/observability/logShipper.js index dae9542..a36c827 100644 --- a/src/observability/logShipper.js +++ b/src/observability/logShipper.js @@ -84,7 +84,7 @@ const ENVELOPE_KEYS = new Set(['ts', 'level', 'service', 'msg', 'version']); // A bare token only where it cannot be confused with the next pair: anything // carrying whitespace, `=` or a quote is JSON-quoted so a reader can split the // tail on unquoted spaces. This is the half of the text format the watch -// collector's parser is written against (claude/scripts/xchain-watch.js). +// collector's parser is written against. function formatFieldValue(value) { if (value === null) return 'null'; if (typeof value === 'string') { diff --git a/src/rateLimitLog.js b/src/rateLimitLog.js new file mode 100644 index 0000000..a214a68 --- /dev/null +++ b/src/rateLimitLog.js @@ -0,0 +1,49 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Security: rate-limit refusal counter/logger + * + * express-rate-limit sets the draft-6 RateLimit-* and Retry-After headers + * and picks the status/body before calling a custom `handler`, so `message` + * moves here and the handler sends it itself. A line per refusal would + * flood the log under real abuse, so this logs once at the first refusal + * of a window and again at the first refusal after a full windowMs has + * elapsed since that line, carrying how many refusals happened since. The + * count only advances on an actual refusal (no timer/interval), so an idle + * limiter never holds the process open. + * + ********************************************************************/ + +'use strict' + +function limitedHandler({ service, name, envVar, limit, windowMs, message, log = console.warn, now = Date.now }) { + const windowSeconds = Math.round(windowMs / 1000) + let count = 0 + let lastLogAt = null + + return function (req, res, next, options) { + res.status(429).json(message) + + count++ + const ts = now() + const dueForLog = lastLogAt === null || (ts - lastLogAt >= windowMs) + if (dueForLog) { + const n = count + log(`${service} rate limit [${name}]: ${n} request${n === 1 ? '' : 's'} refused in the last ${windowSeconds} s (limit ${limit}/${windowSeconds} s); raise ${envVar} if this is legitimate traffic`) + count = 0 + lastLogAt = ts + } + } +} + +module.exports = { limitedHandler } diff --git a/test/security/rate-limit-log.test.js b/test/security/rate-limit-log.test.js new file mode 100644 index 0000000..17d507c --- /dev/null +++ b/test/security/rate-limit-log.test.js @@ -0,0 +1,199 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Security: rate-limit refusal counter/logger + * + * Covers src/rateLimitLog.js directly (the 429 body, the once-per-window + * log line, the silence in between, and the accumulated-count reset) with + * an injectable clock/logger so nothing here needs a real timer, plus one + * end-to-end pass through express-rate-limit to prove the draft-6 headers + * a watchdog checks still land on the 429 this handler sends. A closing + * source check pins src/api.js's limiter to this handler rather than a + * bare `message:` option. + * + * Run: mocha test/security/rate-limit-log.test.js --timeout 30000 + */ + +'use strict' + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const express = require('express') +const rateLimit = require('express-rate-limit') +const http = require('http') +const { limitedHandler } = require('../../src/rateLimitLog.js') + +const MESSAGE = { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } } + +function makeRes() { + return { + statusCode: null, + body: null, + status(code) { this.statusCode = code; return this }, + json(body) { this.body = body; return this } + } +} + +// Extracts the parenthesised call starting at `openIdx` (str[openIdx] === '(') +// by counting balanced parens, so the source check below does not depend on +// formatting/indentation to find where one call ends and the next begins. +function extractBalanced(str, openIdx) { + let depth = 0 + for (let i = openIdx; i < str.length; i++) { + if (str[i] === '(') depth++ + else if (str[i] === ')') { + depth-- + if (depth === 0) return str.slice(openIdx, i + 1) + } + } + throw new Error('unbalanced parens from index ' + openIdx) +} + +describe('Security: rate-limit refusal counter/logger', () => { + + describe('limitedHandler', () => { + it('sends 429 with the exact JSON-RPC body the message option carried', () => { + const handler = limitedHandler({ + service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM', + limit: 60, windowMs: 60000, message: MESSAGE, log: () => {}, now: () => 0 + }) + const res = makeRes() + handler({}, res, () => {}, {}) + assert.strictEqual(res.statusCode, 429) + assert.deepStrictEqual(res.body, MESSAGE) + }) + + it('logs one line at the first refusal of a window, with count 1', () => { + const lines = [] + const handler = limitedHandler({ + service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM', + limit: 60, windowMs: 60000, message: MESSAGE, log: (l) => lines.push(l), now: () => 1000 + }) + handler({}, makeRes(), () => {}, {}) + assert.strictEqual(lines.length, 1) + assert.strictEqual( + lines[0], + 'Encoder rate limit [app-wide]: 1 request refused in the last 60 s (limit 60/60 s); raise ENCODER_RATE_LIMIT_RPM if this is legitimate traffic' + ) + }) + + it('logs nothing more for refusals inside the same window', () => { + const lines = [] + let t = 1000 + const handler = limitedHandler({ + service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM', + limit: 60, windowMs: 60000, message: MESSAGE, log: (l) => lines.push(l), now: () => t + }) + handler({}, makeRes(), () => {}, {}) // first refusal: logs + t = 1000 + 30000 // 30s later, still inside the 60s window + handler({}, makeRes(), () => {}, {}) + handler({}, makeRes(), () => {}, {}) + handler({}, makeRes(), () => {}, {}) + assert.strictEqual(lines.length, 1) + }) + + it('logs the count accumulated since the last line at the first refusal after a full window, then resets', () => { + const lines = [] + let t = 0 + const handler = limitedHandler({ + service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM', + limit: 60, windowMs: 60000, message: MESSAGE, log: (l) => lines.push(l), now: () => t + }) + handler({}, makeRes(), () => {}, {}) // t=0: first ever, logs count 1 + + t = 10000 + for (let i = 0; i < 4; i++) handler({}, makeRes(), () => {}, {}) // 4 silent refusals + + t = 60000 // exactly windowMs after the last logged line + handler({}, makeRes(), () => {}, {}) // 5th silent refusal is the trigger: logs 5, resets + + assert.strictEqual(lines.length, 2) + assert.strictEqual( + lines[1], + 'Encoder rate limit [app-wide]: 5 requests refused in the last 60 s (limit 60/60 s); raise ENCODER_RATE_LIMIT_RPM if this is legitimate traffic' + ) + + // Reset confirmed: a refusal just after the log point does not log again. + t = 60001 + handler({}, makeRes(), () => {}, {}) + assert.strictEqual(lines.length, 2) + }) + }) + + describe('wired into a real express-rate-limit instance', () => { + let server + + afterEach(() => { + if (server) { + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() + server.close() + server = null + } + }) + + it('carries the draft-6 RateLimit-Policy/Limit and Retry-After headers on the 429 this handler sends', async () => { + const app = express() + app.use(rateLimit({ + windowMs: 60000, + limit: 2, + standardHeaders: true, + legacyHeaders: false, + handler: limitedHandler({ + service: 'Encoder', name: 'app-wide', envVar: 'ENCODER_RATE_LIMIT_RPM', + limit: 2, windowMs: 60000, message: MESSAGE, log: () => {} + }) + })) + app.get('/ping', (req, res) => res.json({ ok: true })) + + server = http.createServer(app) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const base = 'http://127.0.0.1:' + server.address().port + + await fetch(base + '/ping') + await fetch(base + '/ping') + const third = await fetch(base + '/ping') + + assert.strictEqual(third.status, 429) + assert.ok(third.headers.get('retry-after'), 'expected a Retry-After header') + assert.ok(third.headers.get('ratelimit-policy'), 'expected a RateLimit-Policy header') + assert.strictEqual(third.headers.get('ratelimit-limit'), '2') + const body = await third.json() + assert.deepStrictEqual(body, MESSAGE) + }) + }) + + describe('src/api.js wiring', () => { + it('routes the limiter through limitedHandler and carries no bare message: option', () => { + const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8') + + const callStart = src.indexOf('const limiter = rateLimit(') + assert.notStrictEqual(callStart, -1, 'expected `const limiter = rateLimit(...)` in src/api.js') + const openIdx = src.indexOf('(', callStart + 'const limiter = rateLimit'.length) + const rateLimitCall = extractBalanced(src, openIdx) + + assert.match(rateLimitCall, /handler:\s*limitedHandler\(/) + + // Strip the nested limitedHandler(...) call (which legitimately carries + // its own `message:` field) before checking the outer options object + // for a bare `message:` - the shape rateLimit() would answer with + // itself, unlogged, if the handler wiring ever regressed. + const handlerCallStart = rateLimitCall.indexOf('limitedHandler(') + const handlerOpenIdx = handlerCallStart + 'limitedHandler'.length + const handlerCall = extractBalanced(rateLimitCall, handlerOpenIdx) + const outerOnly = rateLimitCall.slice(0, handlerCallStart) + rateLimitCall.slice(handlerCallStart + handlerCall.length) + + assert.doesNotMatch(outerOnly, /message\s*:/, 'rateLimit({...}) must not carry a bare message: option outside limitedHandler(...)') + }) + }) +})