blockyard 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/README.md +13 -11
- package/SECURITY.md +2 -2
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/CONFIGURATION.md +6 -4
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +14 -7
- package/docs/INSTALL.md +7 -4
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +26 -7
- package/docs/TROUBLESHOOTING.md +10 -5
- package/docs/USER-GUIDE.md +239 -9
- package/package.json +4 -2
- package/public/css/app.css +87 -0
- package/public/index.html +58 -6
- package/public/js/app.js +60 -19
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +51 -11
- package/public/js/depthchart.js +1 -1
- package/public/js/details3d.js +25 -2
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +3 -2
- package/public/js/mining.js +4 -4
- package/public/js/panels.js +27 -18
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +266 -80
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/scripts/check.js +46 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +108 -10
- package/scripts/shots.mjs +21 -0
- package/scripts/smoke.sh +6 -5
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +432 -56
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +24 -11
- package/server/collect/network.js +19 -9
- package/server/config.js +7 -0
- package/server/http/api.js +70 -13
- package/server/http/server.js +22 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +13 -3
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/systemd/blockyard.service +34 -3
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
# BlockYard Security Audit — 2026-09-16
|
|
2
|
+
|
|
3
|
+
> **STATUS: EVERY FINDING REMEDIATED — 2026-09-16, the same day.** High and medium: H1: a stream client
|
|
4
|
+
> whose socket is full is sent nothing until it drains, and is dropped at 4 MB buffered or a minute
|
|
5
|
+
> blocked; 16 streams per address or account. M1/M2: with accounts off the node connection form
|
|
6
|
+
> answers only a loopback caller (`auth.openNodeConfigFromNetwork` widens it, and a trusted proxy
|
|
7
|
+
> closes it); a save to a new host drops the old endpoint's credentials; a probe of a foreign
|
|
8
|
+
> endpoint reports the kind of failure, not the body. M3: the index build checks its output
|
|
9
|
+
> directory (no symlink, root, home, working or blocks directory, nothing an index does not write)
|
|
10
|
+
> and removes only its own files. M4: every wallet RPC is refused by name. M5: `requestTimeout`
|
|
11
|
+
> is 30 s (the stream is unaffected: measured). M6: audit strings are clamped to 1,024 characters.
|
|
12
|
+
> M7: `package.json` excludes the private notes, and a test holds the pack to tracked files. M8:
|
|
13
|
+
> the shipped unit is sandboxed, and was started under exactly those settings. Each fix has a test
|
|
14
|
+
> in `test/audit-2026-09-16.test.js`. The H1 and M4 tests were run against the old code and failed,
|
|
15
|
+
> and the M3 proof of concept was re-run against the old build, which again deleted the planted file.
|
|
16
|
+
> **Lows and informational findings: all fixed the same day** (commits `d0e5865`, `c8615da`, `d98939e`, `843dc94`),
|
|
17
|
+
> each with tests in `test/audit-2026-09-16-low-*.test.js`.
|
|
18
|
+
>
|
|
19
|
+
> Findings are listed most severe first. Each one says whether it was reproduced (**CONFIRMED**) or
|
|
20
|
+
> found by reading the code (**CODE-READ**).
|
|
21
|
+
|
|
22
|
+
- **Project:** BlockYard, a multi-user web monitor for Bitcoin Core nodes.
|
|
23
|
+
- **Audit date:** 2026-09-16.
|
|
24
|
+
- **Repo state at audit:** branch `main` @ `84e10b8`, clean working tree. The previous audit ended at
|
|
25
|
+
`0fb2c39`; since then 256 commits and about 26,300 inserted lines have landed. Of those, about
|
|
26
|
+
1,900 lines are in `server/`, `scripts/`, `systemd/` and `package.json`. The largest new server
|
|
27
|
+
surface is the resumable address-index build (`server/chain/index/build.js`, landed today), the
|
|
28
|
+
network collector (`server/collect/network.js`), the DOS game file route (`server/http/games.js`)
|
|
29
|
+
and the self-signed TLS generator (`server/tls/selfsigned.js`).
|
|
30
|
+
- **Scope:** the whole codebase: `server/`, `public/`, `scripts/`, `systemd/`, the CI workflow and the
|
|
31
|
+
npm package contents. The machine the audit ran on is a development box that is deliberately not
|
|
32
|
+
configured as a secure install would be, so its own settings are not findings; only the code, the
|
|
33
|
+
shipped defaults and the shipped unit are.
|
|
34
|
+
- **Method:** four auditors ran in parallel, one per area: network-facing server; chain parsing
|
|
35
|
+
and data stores; browser code; scripts, deployment and supply chain. The lead auditor
|
|
36
|
+
re-ran the most consequential proofs before writing this report. Dynamic tests used throwaway
|
|
37
|
+
instances with scratch config files, a fake RPC node (`scripts/fake-node.js`) and hostile
|
|
38
|
+
subclasses of it.
|
|
39
|
+
- **Report generator model:** Claude Opus 5, in Claude Code.
|
|
40
|
+
|
|
41
|
+
## Executive summary
|
|
42
|
+
|
|
43
|
+
| verdict | count |
|
|
44
|
+
|---|---|
|
|
45
|
+
| High | **1**, fixed |
|
|
46
|
+
| Medium | 8, all fixed |
|
|
47
|
+
| Low | 17, all fixed |
|
|
48
|
+
| Informational | 6, all fixed |
|
|
49
|
+
| Findings from the 2026-09-13 and 2026-09-14 audits | fixed, except the two left open by decision |
|
|
50
|
+
| Test evidence | `npm test` 1013/1013 · targeted chain tests 19/19 · decoder fuzzing, 20,000 inputs per decoder |
|
|
51
|
+
|
|
52
|
+
The fixes from both earlier audits hold. The strict Content Security Policy (CSP), with a nonce
|
|
53
|
+
per response, turned every markup injection found this time into markup only: no injected script
|
|
54
|
+
ran in a real browser. Escaping is disciplined almost everywhere. The RPC allowlist resisted every
|
|
55
|
+
case, whitespace and batch trick tried. No credential reaches any API response.
|
|
56
|
+
|
|
57
|
+
The weak points were availability and the "open mode" posture, where accounts are switched off. All four below are fixed:
|
|
58
|
+
|
|
59
|
+
- **One High.** A client that opens the live event stream and stops reading is never dropped.
|
|
60
|
+
400 such connections from one address pushed a test instance from 73 MB to 1.5 GB in three
|
|
61
|
+
minutes. With accounts off, this needs no sign-in.
|
|
62
|
+
- **Open mode is safe only against browsers.** The cross-site check refuses requests that carry
|
|
63
|
+
a foreign `Origin` or `Sec-Fetch-Site`. A script or `curl` sends neither. Any host that can reach
|
|
64
|
+
the port can therefore rewrite the saved node connection, so the node's RPC cookie goes to an
|
|
65
|
+
address of its choosing after the next restart. It can also make the server fetch internal URLs
|
|
66
|
+
and read back the first 200 bytes of each response.
|
|
67
|
+
- **The index build removes its output directory without checking what it is.** A mistyped
|
|
68
|
+
`addressIndex` path, such as a home directory, is deleted recursively on the next start.
|
|
69
|
+
- **The RPC console admits wallet reads that return private keys** (`listdescriptors true`,
|
|
70
|
+
`gethdkeys`), contrary to `docs/SECURITY.md`.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Verification evidence
|
|
75
|
+
|
|
76
|
+
| check | result |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `npm test` | 1013/1013 pass at `84e10b8`. |
|
|
79
|
+
| `node --test test/chain-index.test.js test/chain-decode-property.test.js` | 19/19 pass. |
|
|
80
|
+
| Decoder fuzzing | 20,000 random inputs each into `decodeBlockUndo`, `undoShape`, `decodeTx`, `decodeBlock`, `classifyScript`, `records`, `blockRows` and `addressToScript`, biased toward CompactSize markers. Every failure was a thrown `RangeError` or `ERR_OUT_OF_RANGE`. No hang. Worst input took 64 ms; peak memory 65 MB. |
|
|
81
|
+
| Hostile node in a real browser | A fake node planted markup in every non-hex string, peer field, warning, log line, node label and a failed-login username. The only raw markup that reached the page came from the fields in L2 and L3. `<img onerror>` did not execute under the CSP. |
|
|
82
|
+
| Response headers (throwaway instances) | CSP `script-src 'self' 'nonce-…'`, `style-src 'self'`, `object-src 'none'`, `frame-ancestors 'none'`, `base-uri 'self'`, `form-action 'self'`, plus `X-Frame-Options: DENY`, `nosniff`, `no-referrer`, and HSTS over TLS only. |
|
|
83
|
+
| Path traversal | `/js/../../config/local.json`, `%2e%2e`, `%00`, `/.git/config`, `/games/doom/..%2f..` all return 404 or 400. |
|
|
84
|
+
| RPC allowlist | `stop`, `dumpprivkey`, `dumpwallet`, `setban`, `addnode`, `invalidateblock`, `importdescriptors`, `signmessage`, `send*`, `getnewaddress`, `createpsbt` refused. `Stop`, `STOP`, ` stop`, `"getblockcount,stop"` and array methods refused. `listdescriptors`, `gethdkeys`, `listunspent` allowed (M4). |
|
|
85
|
+
| Secret hygiene | `git log --all -p` over 441 commits: no private keys, tokens or real RPC passwords; only test fixtures. No tracked `.pem`, `.key`, `.bak`, `local.json`, `users.json` or `.env`. |
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## High
|
|
90
|
+
|
|
91
|
+
### H1 — HIGH, FIXED, CONFIRMED: a stalled event-stream reader is never dropped, so memory grows without bound
|
|
92
|
+
|
|
93
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `server/http/sse.js`: a client whose socket is full is sent nothing until it drains (the newest snapshot waits); it is dropped at 4 MB buffered or after 60 s blocked. `server/http/server.js`: at most 16 streams per address in open mode, or per account.
|
|
94
|
+
|
|
95
|
+
**Files:** `server/http/sse.js:76` (reaper), `server/http/sse.js:89-90` (write), `server/http/server.js:62`.
|
|
96
|
+
|
|
97
|
+
The reaper removes a client only when nothing has been written to it for 120 s **and** it has
|
|
98
|
+
received zero bytes:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
if (Date.now() - c.lastWriteAt > 120_000 && c.bytes === 0) this.remove(c);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Neither half can be true for a client that stops reading. Every 15 s ping and every snapshot
|
|
105
|
+
refreshes `lastWriteAt`, and the first `: stream open` frame makes `bytes` non-zero. `write()` sets
|
|
106
|
+
`client.backpressured = true` when the socket is full, and nothing reads that flag. Snapshots of
|
|
107
|
+
20–150 KB per second therefore queue in Node's socket buffer for as long as the connection stays
|
|
108
|
+
open. The per-address stream limiter allows a burst of 120 and refills at 40 per second, so one
|
|
109
|
+
address reaches hundreds of streams in seconds.
|
|
110
|
+
|
|
111
|
+
**Proof:** 400 stalled TLS connections to `/api/stream` from one host, against an open-mode test
|
|
112
|
+
instance. Resident memory went 73 → 293 → 613 → 1066 → 1425 → 1491 MB over about 180 s, with all
|
|
113
|
+
400 clients still registered. Killing the clients brought it back to 205 MB.
|
|
114
|
+
|
|
115
|
+
**Impact:** any client that can open the stream can exhaust memory. With accounts off, that is
|
|
116
|
+
anyone who can reach the port. The process under pressure is the one holding the node's RPC
|
|
117
|
+
cookie. A browser tab that is suspended, or one on a very slow link, builds up the same backlog
|
|
118
|
+
more slowly.
|
|
119
|
+
|
|
120
|
+
**Fix:** track `res.writableLength` per client. Stop pushing snapshots while it is backpressured,
|
|
121
|
+
and drop the client when the buffer passes a ceiling (for example 4 MB) or stays backpressured past
|
|
122
|
+
a timeout. Reap on that condition regardless of `bytes`. Cap concurrent streams per address to a
|
|
123
|
+
small number, for example 8.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Medium
|
|
128
|
+
|
|
129
|
+
### M1 — MEDIUM, FIXED, CONFIRMED: in open mode, a non-browser caller can rewrite the node connection and receive the node's cookie
|
|
130
|
+
|
|
131
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `server/http/api.js`: with accounts off, the node connection save and test answer only a loopback socket peer, and never behind `server.trustProxy`; `auth.openNodeConfigFromNetwork` restores the old reach. A save to a different host or port drops `rpcUser`, `rpcPassword` and `cookieFile`.
|
|
132
|
+
|
|
133
|
+
**Files:** `server/http/api.js:97-99` (`configWriteAllowed`), `server/http/api.js:836-878` (`POST /api/config/node`), `server/http/server.js:240-256` (open-mode cross-site gate).
|
|
134
|
+
|
|
135
|
+
`configWriteAllowed` checks nothing when accounts are off. The open-mode gate refuses a request
|
|
136
|
+
only when it carries a cross-site `Origin` or `Sec-Fetch-Site`. A script sends neither, and a forged
|
|
137
|
+
`Host` with a matching `Origin` also passes. The save merges onto the existing node entry, so
|
|
138
|
+
`cookieFile` survives while `rpcUrl` changes.
|
|
139
|
+
|
|
140
|
+
**Proof:** on an open-mode test instance, an unauthenticated
|
|
141
|
+
`POST /api/config/node {"confirm":"save","rpcUrl":"http://<collector>"}` with no `Origin` was
|
|
142
|
+
saved. After a restart, the monitor sent `Authorization: Basic` with the planted cookie to the
|
|
143
|
+
collector three times. The browser path stays refused, as the 2026-09-13 fix intended.
|
|
144
|
+
|
|
145
|
+
**Impact:** persistent tampering with the monitor's configuration, and disclosure of the node's RPC
|
|
146
|
+
cookie to whoever can reach the port. How useful the cookie is depends on whether the node's RPC
|
|
147
|
+
port is reachable from the attacker.
|
|
148
|
+
|
|
149
|
+
**Relation to earlier audits:** this is 2026-09-14 **I1**, left open by decision. It is raised to
|
|
150
|
+
Medium here because it was reproduced end to end.
|
|
151
|
+
|
|
152
|
+
**Fix:** with accounts off, refuse config and settings writes unless the caller is on loopback or
|
|
153
|
+
an explicit `allowOpenConfigWrites` option is set. On save, drop `cookieFile`, `rpcUser` and
|
|
154
|
+
`rpcPassword` whenever the `rpcUrl` host changes.
|
|
155
|
+
|
|
156
|
+
### M2 — MEDIUM, FIXED, CONFIRMED: the node-connection probe is a server-side request forgery (SSRF) that reflects the response
|
|
157
|
+
|
|
158
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `server/http/api.js`: a probe of any endpoint but the configured one returns only the kind of failure (timeout, unreachable, not JSON-RPC, RPC error), never the endpoint's reply. The loopback gate from M1 also applies.
|
|
159
|
+
|
|
160
|
+
**Files:** `server/http/api.js:772-834` (`POST /api/config/node/test`), `server/rpc/client.js:361-366` (error text).
|
|
161
|
+
|
|
162
|
+
The 2026-09-13 fix holds: the probe sends no credentials to a new endpoint (the collector saw no
|
|
163
|
+
`Authorization`). But the route is reachable without sign-in in open mode by any non-browser
|
|
164
|
+
caller, as in M1. It POSTs to any URL the caller names. When the reply is not JSON, the error
|
|
165
|
+
message returns the first 200 characters of the body.
|
|
166
|
+
|
|
167
|
+
**Proof:** open-mode test instance, no `Origin` header,
|
|
168
|
+
`POST /api/config/node/test {"rpcUrl":"http://127.0.0.1:<port>/internal"}` →
|
|
169
|
+
`{"error":{"message":"RPC returned non-JSON (INTERNAL-ADMIN-PANEL secret-token=abc123 …)"}}`.
|
|
170
|
+
The distinct errors for refused, timed out, unparseable and unauthorised also map which internal
|
|
171
|
+
ports are open.
|
|
172
|
+
|
|
173
|
+
**Fix:** require a positive same-origin signal for stateful POSTs in open mode, for example an
|
|
174
|
+
anonymous double-submit token. Report only `reachable`, `refused`, `timed out` or `not an RPC
|
|
175
|
+
server`, never the body.
|
|
176
|
+
|
|
177
|
+
### M3 — MEDIUM, FIXED, CONFIRMED: the index build deletes its output directory recursively with no check
|
|
178
|
+
|
|
179
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `server/chain/index/build.js`: `checkOutputDir` refuses a symlink, the filesystem root, the home and working directories, the blocks directory or anything containing it, and any directory holding a name an index does not write. The build removes only index entries, never the directory.
|
|
180
|
+
|
|
181
|
+
**Files:** `server/chain/index/build.js:306-309`, reached from `server/main.js` at startup and from `scripts/index-build.js`.
|
|
182
|
+
|
|
183
|
+
When there is no trusted build journal, `buildIndex` runs `rmSync(out, { recursive: true, force: true })`.
|
|
184
|
+
Nothing checks that `out` is empty, looks like an index, is not a symlink, or is not `/`, a home
|
|
185
|
+
directory, the working directory, the node's datadir or its blocks directory. The server starts
|
|
186
|
+
this build by itself whenever a node has `addressIndex` set and no `manifest.json` is there. The
|
|
187
|
+
deletion happens after the heights phase, minutes after start, when nobody is watching.
|
|
188
|
+
|
|
189
|
+
**Proof:** `buildIndex` with a fake RPC, an empty blocks directory and `out` set to a directory
|
|
190
|
+
holding `.ssh/id_x`. The build threw "1 heights were not indexed". Afterwards `.ssh/id_x` no longer
|
|
191
|
+
existed. With `out` a symlink, the link was replaced by a real directory.
|
|
192
|
+
|
|
193
|
+
**Impact:** a typo such as `"addressIndex": "/home/bitcoin"`, `"."` or `"data"`, or `--out ~` on the
|
|
194
|
+
command line, destroys that directory.
|
|
195
|
+
|
|
196
|
+
**Fix:** never remove `out` itself. Create it with a marker file such as `.blockyard-index`, and
|
|
197
|
+
refuse to build in a non-empty directory that lacks the marker. When clearing, delete only the
|
|
198
|
+
index's own names: `bucket-*.unsorted`, `seg-*`, `manifest.json`, `build-journal.json`, `*.tmp`,
|
|
199
|
+
`live.log`, `layers/`. `lstat` `out` and refuse a symlink.
|
|
200
|
+
|
|
201
|
+
### M4 — MEDIUM, FIXED, CODE-READ (classification CONFIRMED): the RPC allowlist admits wallet reads that return private keys
|
|
202
|
+
|
|
203
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `server/rpc/allowlist.js`: `WALLET_METHODS`, every method in Core's wallet category plus the legacy wallet methods, is refused by name before any prefix rule.
|
|
204
|
+
|
|
205
|
+
**File:** `server/rpc/allowlist.js:45` (the `get` and `list` allow prefixes).
|
|
206
|
+
|
|
207
|
+
`classifyMethod` returns `allowed: true` for `listdescriptors`, `gethdkeys`, `listunspent`,
|
|
208
|
+
`listtransactions`, `getbalances`, `getwalletinfo`, `listwalletdir` and `getaddressinfo`. With an
|
|
209
|
+
unlocked wallet loaded, `listdescriptors true` and `gethdkeys {"private":true}` return private key
|
|
210
|
+
material. `docs/SECURITY.md` says key-material methods are refused by name and that the monitor has
|
|
211
|
+
no wallet access. The same prefix rule was 2026-09-13 finding 2, left open by decision; these two
|
|
212
|
+
methods make it concrete.
|
|
213
|
+
|
|
214
|
+
**Impact:** anyone allowed to use the RPC console can read wallet private keys. With accounts off
|
|
215
|
+
that is anyone who can reach the port.
|
|
216
|
+
|
|
217
|
+
**Fix:** deny `listdescriptors` and `gethdkeys` by exact name. Better, deny every wallet RPC,
|
|
218
|
+
since the monitor has no use for them, and correct the documentation.
|
|
219
|
+
|
|
220
|
+
### M5 — MEDIUM, FIXED, CONFIRMED: no deadline on reading a request body
|
|
221
|
+
|
|
222
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `server/http/server.js`: `requestTimeout` is 30 s. Measured: a stream outlives the deadline; a trickled body gets 408.
|
|
223
|
+
|
|
224
|
+
**File:** `server/http/server.js:62` (`server.requestTimeout = 0`).
|
|
225
|
+
|
|
226
|
+
The timeout is zeroed for every request so the event stream is not cut off. That also removes the
|
|
227
|
+
body-read deadline from every other route. `headersTimeout` (15 s) bounds only the headers.
|
|
228
|
+
|
|
229
|
+
**Proof:** `POST /api/login` with `Content-Length: 100000`, sending one byte every 20 s, was still
|
|
230
|
+
open after 95 s.
|
|
231
|
+
|
|
232
|
+
**Fix:** exempt only the stream, for example with `req.setTimeout(0)` on that socket. Restore a
|
|
233
|
+
finite `requestTimeout`, or add a deadline inside the body reader.
|
|
234
|
+
|
|
235
|
+
### M6 — MEDIUM, FIXED, CONFIRMED: the audit trail can be flushed out with oversized RPC method names
|
|
236
|
+
|
|
237
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `server/main.js`: every string in an audit row is clamped to 1,024 characters; `/api/rpc` clamps the method it audits and echoes to 64.
|
|
238
|
+
|
|
239
|
+
**Files:** `server/http/api.js:602-611`, `server/main.js:271-283`, `server/config.js:200-201`.
|
|
240
|
+
|
|
241
|
+
`/api/rpc` writes the caller's `method` string into the audit row verbatim. The body limit is 1 MB,
|
|
242
|
+
the log rotates at 8 MB and keeps 5 files.
|
|
243
|
+
|
|
244
|
+
**Proof:** with accounts on, about 60 requests carrying a 900 KB method name from a signed-in viewer
|
|
245
|
+
rotated the log through all retained files. The earlier `user-create` rows were gone. In open mode
|
|
246
|
+
the rows are written the same way, so an anonymous caller can do it too.
|
|
247
|
+
|
|
248
|
+
**Fix:** clamp `method` and every other free-form audited field to a short length, for example 128
|
|
249
|
+
characters, before writing.
|
|
250
|
+
|
|
251
|
+
### M7 — MEDIUM, FIXED, CONFIRMED: the npm package includes private notes that `.gitignore` excludes
|
|
252
|
+
|
|
253
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `package.json`: `!docs/STATE-*.md` and `!docs/PRIVATE-*.md`. A test fails if `npm pack` would publish any file git does not track.
|
|
254
|
+
|
|
255
|
+
**File:** `package.json`, `"files"`: `"docs/*.md"`.
|
|
256
|
+
|
|
257
|
+
npm selects files by the `files` globs and does not read `.gitignore`. `docs/STATE-*.md` and
|
|
258
|
+
`docs/PRIVATE-*.md` are therefore packed. `test/privacy.test.js` scans only `git ls-files`, so
|
|
259
|
+
nothing catches this.
|
|
260
|
+
|
|
261
|
+
**Proof:** `npm pack --dry-run` today lists `docs/PRIVATE-LEADERBOARD.md` and
|
|
262
|
+
`docs/STATE-2026-09-09.md`. The published `blockyard-0.0.9.tgz` contains both. 0.1.0 does not. The
|
|
263
|
+
0.0.9 copies hold internal paths, service names and port numbers. No credentials, keys or IP
|
|
264
|
+
addresses were found in them.
|
|
265
|
+
|
|
266
|
+
**Fix:** list the published docs explicitly, or add negations for `docs/STATE-*` and
|
|
267
|
+
`docs/PRIVATE-*`. Add a test that runs `npm pack --dry-run --json` and fails on any gitignored path.
|
|
268
|
+
Consider deprecating 0.0.9.
|
|
269
|
+
|
|
270
|
+
### M8 — MEDIUM, FIXED, CODE-READ: the shipped systemd unit has almost no sandboxing, and its comment says otherwise
|
|
271
|
+
|
|
272
|
+
> **FIXED 2026-09-16** (commit `8e9c6a4`, test in `test/audit-2026-09-16.test.js`). `systemd/blockyard.service`: `ProtectSystem=strict` with `ReadWritePaths` for `data/` and `config/`, `ProtectHome=read-only`, `PrivateTmp`, no capabilities, `SystemCallFilter=@system-service`, restricted address families, `UMask=0077`; the misleading comment is replaced. The app was started under exactly these settings as a transient service and served pages and saved settings.
|
|
273
|
+
|
|
274
|
+
**File:** `systemd/blockyard.service`.
|
|
275
|
+
|
|
276
|
+
The unit sets `NoNewPrivileges=yes` and nothing else of note: no `ProtectSystem`, `ProtectHome`,
|
|
277
|
+
`PrivateTmp`, `ReadWritePaths`, `CapabilityBoundingSet`, `RestrictAddressFamilies`,
|
|
278
|
+
`SystemCallFilter` or `UMask`. Its comment describes a read-only `/home` that is not configured. It
|
|
279
|
+
also claims `ProtectSystem=strict` would stop the service reading the node cookie, but `strict`
|
|
280
|
+
makes paths read-only; it does not block reading. `docs/INSTALL.md` §6 correctly recommends a
|
|
281
|
+
dedicated system account, but anyone who copies the unit as shipped gets no filesystem isolation.
|
|
282
|
+
Any code-execution bug would reach everything the service account can.
|
|
283
|
+
|
|
284
|
+
**Fix:** add `ProtectSystem=strict`, `ProtectHome=read-only` (or `tmpfs` with `BindReadOnlyPaths=`
|
|
285
|
+
for the cookie), `ReadWritePaths=` for `data/`, `config/` and the index directory, `PrivateTmp=yes`,
|
|
286
|
+
`CapabilityBoundingSet=`, `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`,
|
|
287
|
+
`SystemCallFilter=@system-service`, `ProtectProc=invisible` and `UMask=0077`. Correct the comment.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Low
|
|
292
|
+
|
|
293
|
+
### L1 — LOW, FIXED, CONFIRMED: open redirect after sign-in
|
|
294
|
+
|
|
295
|
+
> **FIXED 2026-09-16** (commit `d0e5865`). `public/js/safenext.js`: the target is resolved against this origin and kept only if the origin is unchanged; `//host` and `/\host` go to `/`.
|
|
296
|
+
|
|
297
|
+
**File:** `public/js/login.js:79-80`.
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
window.location.replace(back && back.startsWith('/') ? back : '/');
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`//host` and `/\host` both start with `/` and leave the site. Signing in at
|
|
304
|
+
`/login?next=//<other-origin>/` landed on the other origin. A crafted link can send a user from a
|
|
305
|
+
genuine sign-in page to a lookalike "session expired" page. The server never sets `next` itself.
|
|
306
|
+
|
|
307
|
+
**Fix:** accept only `^/(?![/\\])`, or resolve with `new URL(back, location.origin)` and require the
|
|
308
|
+
same origin. Or remove `next`.
|
|
309
|
+
|
|
310
|
+
### L2 — LOW, FIXED, CONFIRMED: some RPC fields reach the page unescaped
|
|
311
|
+
|
|
312
|
+
> **FIXED 2026-09-16** (commit `d0e5865`). `kv()` in `public/js/panels.js` escapes every value unless it is wrapped in `raw()`, markup the file builds itself; the mining panel escapes its values; `server/collect/monitor.js` passes `chain`, `pruned`, `initialblockdownload` and `unbroadcastcount` on only in their own types.
|
|
313
|
+
|
|
314
|
+
**Files:** `public/js/panels.js:84, 93, 95, 156, 291-292, 517`; `kv()` in `panels.js` (about line 833) does
|
|
315
|
+
not escape; `setText` in `public/js/app.js:468-470` assigns `innerHTML`. Server side:
|
|
316
|
+
`server/collect/monitor.js:1824, 1878, 1904` copy the fields without type checks.
|
|
317
|
+
|
|
318
|
+
`getblockchaininfo.chain`, `.pruned`, `.initialblockdownload`, `getmempoolinfo.unbroadcastcount`, the
|
|
319
|
+
budget values and the node-self values are rendered as HTML. A hostile node returned them as strings
|
|
320
|
+
with `<u>` and `<img>` appended, and those elements appeared on the Chain, Mempool and Node pages.
|
|
321
|
+
The CSP blocked script. What remains is fake text, links and forms, with `form-action 'self'`
|
|
322
|
+
limiting the forms. Only a malicious RPC endpoint, or someone who can tamper with plain-HTTP RPC to a
|
|
323
|
+
remote node, can do this. Peers and miners cannot.
|
|
324
|
+
|
|
325
|
+
**Fix:** escape in `kv()` by default, with an explicit opt-in for markup. On the server, validate
|
|
326
|
+
`chain` against the known chain names, booleans with `typeof`, and counts with `Number.isFinite`.
|
|
327
|
+
|
|
328
|
+
### L3 — LOW, FIXED, CONFIRMED: the node's RPC URL is rendered unescaped, and shown to viewers
|
|
329
|
+
|
|
330
|
+
> **FIXED 2026-09-16** (commit `d0e5865`). escaped by `kv()`; `displayUrl()` in `server/rpc/client.js` removes a username and password from every URL sent to a client (Node page, node list, sync endpoint).
|
|
331
|
+
|
|
332
|
+
**File:** `public/js/panels.js:477`, source `server/rpc/client.js:402`.
|
|
333
|
+
|
|
334
|
+
An `rpcUrl` with markup in its query string passed URL validation and injected elements on the Node
|
|
335
|
+
page for every viewer. Setting it needs an admin, or anyone in open mode (M1). The URL is also shown
|
|
336
|
+
to every viewer, so a URL written as `http://user:pass@host` would expose those credentials.
|
|
337
|
+
|
|
338
|
+
**Fix:** escape it. On the server, strip the userinfo part before sending the URL to any client.
|
|
339
|
+
|
|
340
|
+
### L4 — LOW, FIXED, CONFIRMED: one malformed byte in a coinbase stalls pool attribution
|
|
341
|
+
|
|
342
|
+
> **FIXED 2026-09-16** (commit `843dc94`). `parsePushes` checks a push's length bytes exist before reading them; `decodeCoinbaseSafe` never throws; the monitor and the pool history record an undecodable coinbase as an unknown pool and move on, while RPC failures still retry.
|
|
343
|
+
|
|
344
|
+
**Files:** `server/collect/mining.js:44-45` (`parsePushes`), retry loops in `server/collect/monitor.js:814-831` and `server/collect/network.js:242-244`.
|
|
345
|
+
|
|
346
|
+
A coinbase scriptSig that ends in `OP_PUSHDATA2` or `OP_PUSHDATA4` with no length bytes makes
|
|
347
|
+
`readUInt16LE`/`readUInt32LE` throw. Consensus allows any bytes after the BIP34 height, so any pool can
|
|
348
|
+
do this to its own block for free. The failed height goes back to the front of the queue with up to
|
|
349
|
+
60 s backoff, so every later block waits behind it. The pool-history view retries the whole chunk of
|
|
350
|
+
eight every 15 s and stops filling. `decodeCoinbase('03aabbcc4d')` throws `ERR_OUT_OF_RANGE`.
|
|
351
|
+
|
|
352
|
+
**Fix:** stop parsing when `i + head > bytes.length`. Catch per block and record it as unparseable
|
|
353
|
+
instead of retrying forever.
|
|
354
|
+
|
|
355
|
+
### L5 — LOW, FIXED, CONFIRMED: one absurd order book can freeze the server and allocate gigabytes
|
|
356
|
+
|
|
357
|
+
> **FIXED 2026-09-16** (commit `843dc94`). a book whose mid is more than 20% from the other books and recent tickers is not drawn; the grid is capped at 2,000 levels; bodies are read with a 5 MB cap; redirects are refused.
|
|
358
|
+
|
|
359
|
+
**Files:** `server/collect/markets.js:114-120, 182-186, 234-247`.
|
|
360
|
+
|
|
361
|
+
The depth grid size is `ceil(mid × 0.24 / 50)`, where `mid` is the median of the books' mid-prices.
|
|
362
|
+
With one book answering, that book alone sets it. A book of bid 60,000 and ask 2×10¹⁰ gave 48 million
|
|
363
|
+
levels: `pollBooks` blocked for 10.2 s and memory reached 5 GB for one snapshot, which is kept for an
|
|
364
|
+
hour. Response bodies have no size cap, only an 8 s timeout. Reaching this needs a broken,
|
|
365
|
+
compromised or intercepted exchange API; TLS verification is on. Market polling is off by default.
|
|
366
|
+
|
|
367
|
+
**Fix:** discard a book whose mid is more than 20% from the ticker median, and clamp the level count,
|
|
368
|
+
for example to 2,000. Stream response bodies with a byte cap.
|
|
369
|
+
|
|
370
|
+
### L6 — LOW, FIXED, CONFIRMED: a log-parsing pattern takes cubic time on long lines
|
|
371
|
+
|
|
372
|
+
> **FIXED 2026-09-16** (commit `843dc94`). lines are cut at 8 KB before matching and an unterminated line's carry is capped; `legDown`, `bandwidthTick` and eight other rules found by fuzzing rewritten so no run of spaces can be split two ways. Output on 312 known lines is byte-identical; a 20,000-space line parses in under 100 ms.
|
|
373
|
+
|
|
374
|
+
**File:** `server/collect/logparse.js`, the `legDown` rule; `parseLine` runs every rule unanchored on the main thread.
|
|
375
|
+
|
|
376
|
+
`'[mux:1] next peer a unreachable: ' + ' '.repeat(N) + 'x'` took 5 ms at 250 spaces, 177 ms at 1,000,
|
|
377
|
+
1.4 s at 2,000, and did not finish in 300 s at 20,000. The `bandwidth` rule is quadratic. `splitLines`
|
|
378
|
+
keeps an unterminated line in `carry` with no limit. It needs a multi-kilobyte line in the node's log.
|
|
379
|
+
|
|
380
|
+
**Fix:** truncate lines to 4–8 KB before matching and cap `carry` the same way. Replace
|
|
381
|
+
`\s*(.+?)\s*` with a form that cannot backtrack across the same spaces, such as `\s*(\S.*?)\s*`.
|
|
382
|
+
|
|
383
|
+
### L7 — LOW, FIXED, CONFIRMED: the height table loops forever when it is full
|
|
384
|
+
|
|
385
|
+
> **FIXED 2026-09-16** (commit `d98939e`). `HeightTable.forTip()` sizes the table from the tip; `set()` throws past a 0.75 load and `get()` stops after one lap.
|
|
386
|
+
|
|
387
|
+
**Files:** `server/chain/index/heights.js:141, 147`; capacity fixed at `1<<21` in `build.js:30`.
|
|
388
|
+
|
|
389
|
+
The open-addressing table has no load check, so at 2,097,152 entries `set` never finds a free slot.
|
|
390
|
+
It runs on the server's main thread during the background build, so a node reporting a tip above that
|
|
391
|
+
height hangs the monitor. Mainnet reaches that height around 2046. Inserting the 1,025th key into a
|
|
392
|
+
table of capacity 1,024 hung until the 5 s test timeout.
|
|
393
|
+
|
|
394
|
+
**Fix:** size capacity from the tip, at least twice `tip + 1`, and throw past a load of 0.75.
|
|
395
|
+
|
|
396
|
+
### L8 — LOW, FIXED, CONFIRMED: the index trusts the coin count of an undo record
|
|
397
|
+
|
|
398
|
+
> **FIXED 2026-09-16** (commit `d98939e`). a transaction whose input count differs from its undo record's coin count throws, naming the files and offsets.
|
|
399
|
+
|
|
400
|
+
**File:** `server/chain/index/rows.js:514-521` (the `nin` from `walkTx` is ignored).
|
|
401
|
+
|
|
402
|
+
Blocks and undo records are paired by `hash256(prevhash ‖ undo)`, which does not commit to the block's
|
|
403
|
+
contents. Two sibling blocks in one file with the same parent and transaction count can each match
|
|
404
|
+
either undo record. A wrong pick is not detected, and rows are written with the wrong spent scripts
|
|
405
|
+
or amounts. A transaction with two inputs paired with an undo holding one coin produced three rows and
|
|
406
|
+
no error.
|
|
407
|
+
|
|
408
|
+
**Fix:** throw when `coins.length !== nin`, which also turns a mispairing into a detected failure.
|
|
409
|
+
|
|
410
|
+
### L9 — LOW, FIXED, CODE-READ: nothing stops two builds writing the same index directory
|
|
411
|
+
|
|
412
|
+
> **FIXED 2026-09-16** (commit `d98939e`). `build.lock` in the output directory (exclusive create, pid and token; a dead pid's lock is taken over; released only by its owner).
|
|
413
|
+
|
|
414
|
+
**Files:** `server/chain/index/build.js`, `server/main.js`.
|
|
415
|
+
|
|
416
|
+
The server's automatic build and `scripts/index-build.js --out <same dir>` can run at once. The server's
|
|
417
|
+
own failure message suggests running that command, and a restart re-enters the build. Each can delete,
|
|
418
|
+
truncate and rename the other's files. The CRC checks make most interleavings fail, but not all of them
|
|
419
|
+
for segments.
|
|
420
|
+
|
|
421
|
+
**Fix:** take an exclusive lock file in `out` (`openSync(lock, 'wx')`, with the PID and a stale-PID check).
|
|
422
|
+
|
|
423
|
+
### L10 — LOW, FIXED, CODE-READ: file modes follow the umask, and temporary files follow symlinks
|
|
424
|
+
|
|
425
|
+
> **FIXED 2026-09-16** (commit `d98939e`). explicit 0600 files and 0700 directories across the index, live index, ledger, history and audit trail, with existing files tightened on open; temp files unlinked and opened with `wx`; resume refuses a symlinked bucket.
|
|
426
|
+
|
|
427
|
+
**Files:** `writeFileAtomic` in `build.js`, `worker.js` segment writes, `live.js` `writeAtomic`, bucket
|
|
428
|
+
files, `server/store/ledger.js`, `server/store/audit.js`, `server/store/history.js`, directory creation
|
|
429
|
+
in `server/main.js:29-30`.
|
|
430
|
+
|
|
431
|
+
Index files, the journal, the live log and the ledger are created without a mode, so on a host with
|
|
432
|
+
umask `0002` they are group-writable. `audit.js` and `history.js` pass `0o600`, which only applies on
|
|
433
|
+
creation, so a file created before the 2026-09-14 fix keeps its old mode. Every `.tmp` is opened with flag `w`, which follows a symlink. Anyone who can create
|
|
434
|
+
entries in `out` or `data/` can plant `manifest.json.tmp` pointing at another file and have it
|
|
435
|
+
overwritten as the service account.
|
|
436
|
+
|
|
437
|
+
The build journal's SHA-256 detects a torn write; it is not authentication. With write access to
|
|
438
|
+
`out`, a forged journal plus a `bucket-XX.unsorted` symlink makes a resume truncate the symlink's target.
|
|
439
|
+
A forged journal can also mark buckets sorted over arbitrary segment files of the right size, so a wrong
|
|
440
|
+
index is served. All of this needs write access to `out`, which already means control of the index.
|
|
441
|
+
Journal-driven deletion was checked and stays inside `out`.
|
|
442
|
+
|
|
443
|
+
**Fix:** pass explicit modes, `fchmod` existing audit and data files at startup, create directories with
|
|
444
|
+
`0o700`, set `UMask=0077` in the unit. Unlink temporary files first and open them with `wx`; `lstat`
|
|
445
|
+
bucket files before truncating.
|
|
446
|
+
|
|
447
|
+
### L11 — LOW, FIXED, CONFIRMED: anonymous responses disclose filesystem paths
|
|
448
|
+
|
|
449
|
+
> **FIXED 2026-09-16** (commit `d0e5865`). `shortPath()`: viewers see the last two parts of the cookie, log and settings paths; admins still see full paths for the settings file.
|
|
450
|
+
|
|
451
|
+
**Routes:** `/api/telemetry` (`cookieSource`), `/api/state` (`getrpcinfo.logpath`).
|
|
452
|
+
|
|
453
|
+
In open mode these return the full paths of the node cookies and debug log, which include the
|
|
454
|
+
service account's home directory name. The comment on `/api/about` says usernames and paths are withheld
|
|
455
|
+
on purpose; these routes defeat that. `/api/about` also returns kernel, CPU model and memory, and
|
|
456
|
+
`/api/peers` returns peer addresses, both by design for a viewer.
|
|
457
|
+
|
|
458
|
+
**Fix:** send a boolean such as `cookieFound`, or a basename, and drop `logpath` for viewers.
|
|
459
|
+
|
|
460
|
+
### L12 — LOW, FIXED, CODE-READ: `setup.js` shows the RPC password on screen and on the command line
|
|
461
|
+
|
|
462
|
+
> **FIXED 2026-09-16** (commit `c8615da`). the password prompt no longer echoes; `--rpc-password-file PATH` and `--rpc-password -` (stdin); `--rpc-password P` warns; `local.json` and its backups are chmod 0600 on every write.
|
|
463
|
+
|
|
464
|
+
**File:** `scripts/setup.js:222-229, 261, 272, 297`.
|
|
465
|
+
|
|
466
|
+
The `secret: true` prompt only hides the default value; `readline` still echoes what is typed.
|
|
467
|
+
`--rpc-password` is visible in `ps` and shell history. `writeLocalConfig --force` never re-applies `0600`
|
|
468
|
+
to an existing file. `scripts/manage-users.js` already does all of this correctly.
|
|
469
|
+
|
|
470
|
+
**Fix:** use muted input as `manage-users.js` does, accept `--rpc-password-file` or stdin, and `chmod 0600`
|
|
471
|
+
after writing.
|
|
472
|
+
|
|
473
|
+
### L13 — LOW, FIXED, CONFIRMED: malformed percent-encoding in a route parameter returns 500
|
|
474
|
+
|
|
475
|
+
> **FIXED 2026-09-16** (commit `d0e5865`). `matchRoute` answers a malformed escape with 400.
|
|
476
|
+
|
|
477
|
+
**File:** `server/http/server.js:305-316` (`matchRoute`).
|
|
478
|
+
|
|
479
|
+
`POST /api/users/%E0%A4%A/role` → 500 `internal error`. No information leaks; it is a wrong status and a
|
|
480
|
+
noisy log line.
|
|
481
|
+
|
|
482
|
+
**Fix:** catch the `decodeURIComponent` error and return 400. The browser has the same pattern in
|
|
483
|
+
`public/js/explorer.js:23` (I1).
|
|
484
|
+
|
|
485
|
+
### L14 — LOW, FIXED, CONFIRMED: sign-in lockout by address lets one client lock out a shared address
|
|
486
|
+
|
|
487
|
+
> **FIXED 2026-09-16** (commit `d0e5865`). the lock is per username and address pair (8); an address spraying across accounts locks at 40 and an account ground from many addresses at 80, so one address cannot lock out another.
|
|
488
|
+
|
|
489
|
+
**Files:** `server/auth/sessions.js:162-184`, `server/http/api.js:291-294`.
|
|
490
|
+
|
|
491
|
+
Eight failures lock both the username and the source address for 10 minutes, so one person behind a
|
|
492
|
+
shared NAT or proxy can lock everyone behind it out of every account. This is the usual trade-off for
|
|
493
|
+
lockouts, and the per-address token bucket limits floods. Worth knowing; not urgent.
|
|
494
|
+
|
|
495
|
+
### L15 — LOW, FIXED, CODE-READ: `pool-map.js` fetches without pinning and follows any redirect
|
|
496
|
+
|
|
497
|
+
> **FIXED 2026-09-16** (commit `c8615da`). https-only redirects on both fetch paths; a pool-by-pool diff and `--yes` or a confirmation before replacing the map; `--expect-sha256`; the coverage check uses the configured port and scheme.
|
|
498
|
+
|
|
499
|
+
**File:** `scripts/pool-map.js:253-283, 299`.
|
|
500
|
+
|
|
501
|
+
It fetches the pool list over verified HTTPS but checks no expected hash; the SHA-256 is recorded, not
|
|
502
|
+
compared. The `curl -fsSL` fallback follows redirects to any protocol, including plain HTTP. The effect
|
|
503
|
+
is limited to display labels, which are escaped. The coverage check hardcodes port 8088, while the
|
|
504
|
+
default is now 21000, so coverage is never measured.
|
|
505
|
+
|
|
506
|
+
**Fix:** add `--proto =https --proto-redir =https`, show a diff before overwriting, and read the port from config.
|
|
507
|
+
|
|
508
|
+
### L16 — LOW, FIXED, CONFIRMED: security documentation has drifted from the code
|
|
509
|
+
|
|
510
|
+
> **FIXED 2026-09-16** (commit `d0e5865`). `SECURITY.md` supports 0.1.x; `docs/CONFIGURATION.md` gives the real `BLOCKYARD_AUTH` default; `docs/SECURITY.md` open-mode table and wallet claim corrected (with the mediums); `scripts/smoke.sh` comments corrected, and its open-access instance now actually sets `BLOCKYARD_AUTH=0`.
|
|
511
|
+
|
|
512
|
+
- `SECURITY.md`'s supported-versions table lists 0.0.9 and says nothing else was released; 0.0.1 and 0.1.0 are on npm.
|
|
513
|
+
- `docs/CONFIGURATION.md:403` gives the `BLOCKYARD_AUTH` default as `false`; the code default is `true` (`server/config.js:221`).
|
|
514
|
+
- The shipped unit's comment and `scripts/smoke.sh` still say accounts are off by default.
|
|
515
|
+
- `docs/SECURITY.md`'s open-mode table lists "every node write" as closed but omits that the node connection and Display settings are writable (M1).
|
|
516
|
+
- `docs/SECURITY.md` says the monitor has no wallet access (M4).
|
|
517
|
+
|
|
518
|
+
Checked and still true: loopback bind and sign-in by default, TLS key `0600` in a `0700` directory,
|
|
519
|
+
the full header set, `/api/audit` and `/api/users` refused in open mode, node writes gated, market
|
|
520
|
+
polling off by default, no credential value in any API response.
|
|
521
|
+
|
|
522
|
+
### L17 — LOW, FIXED, CONFIRMED: game files are served without sign-in, and CI actions are pinned by tag
|
|
523
|
+
|
|
524
|
+
> **FIXED 2026-09-16** (commit `c8615da`). CI actions pinned to commit SHAs with Dependabot for updates. The game files were already refused without a session when accounts are on; they were reachable here only because the audited machine runs in open mode, which is a configuration choice and not a finding.
|
|
525
|
+
|
|
526
|
+
- `/games/doom/DOOM1.WAD` returns 200 and 4 MB with no session. No data is exposed, but anyone who can
|
|
527
|
+
reach the port can pull tens of megabytes repeatedly. Consider requiring a session for `/games/`.
|
|
528
|
+
- `.github/workflows/test.yml` uses `actions/checkout@v7` and `actions/setup-node@v7` by tag. The workflow
|
|
529
|
+
already has `permissions: contents: read`, no `pull_request_target`, no secrets and no install step.
|
|
530
|
+
Pin both to commit SHAs.
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## Informational
|
|
535
|
+
|
|
536
|
+
> **ALL FIXED 2026-09-16.** I1: a malformed explorer link routes home (`d0e5865`). I2: the depth note is
|
|
537
|
+
> escaped (`d0e5865`). I3: `--workers` must be a whole number of at least 1 (`d98939e`). I4: malformed
|
|
538
|
+
> `live.log` records are cut as a torn tail, bad layers are skipped and warned about, manifest `blockRows`
|
|
539
|
+
> is bounded, and an undo failure names its files and offsets (`d98939e`). I5: `config/*.bak-*` is ignored
|
|
540
|
+
> and backups are 0600 (`c8615da`). I6: `games/SHA256SUMS` and a provenance note in `docs/ARCHITECTURE.md`
|
|
541
|
+
> (`c8615da`).
|
|
542
|
+
|
|
543
|
+
- **I1** — `public/js/explorer.js:23`: `decodeURIComponent` on a hash like `#explorer/tx/%E0` throws and
|
|
544
|
+
stops that tab's explorer rendering. Wrap it and fall back to the explorer home.
|
|
545
|
+
- **I2** — `public/js/depthchart.js:245`: `d.note` goes into `innerHTML` unescaped. It is a fixed server
|
|
546
|
+
string today; escape it before it ever becomes dynamic.
|
|
547
|
+
- **I3** — `scripts/index-build.js:181`: `--workers abc` or `--workers 0` makes a pool with no workers.
|
|
548
|
+
With `--files` that writes a "successful" manifest with zero rows. Validate `>= 1`, as the server path does.
|
|
549
|
+
- **I4** — robustness of self-written files: a CRC-valid but short `live.log` record throws in the
|
|
550
|
+
constructor; a bad `.idx` length throws in `reloadLayers`; a manifest's `blockRows` sizes an allocation
|
|
551
|
+
unchecked; one corrupt undo record fails its whole file and so the whole build. All fail safe.
|
|
552
|
+
- **I5** — `.gitignore` has no pattern for `config/*.bak-*`, which `setup.js` writes. Such files hold the
|
|
553
|
+
RPC password and would show as untracked, one `git add` away from a commit.
|
|
554
|
+
- **I6** — the game binaries under `games/` are served, never executed on the server (there is no
|
|
555
|
+
`child_process` in `server/`), and are excluded from the npm package. Wolfenstein 3D ships no licence
|
|
556
|
+
text beyond `file_id.diz`, and no provenance or hash manifest exists for any game.
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
560
|
+
## Earlier audits, re-verified
|
|
561
|
+
|
|
562
|
+
### 2026-09-13 (`docs/SECURITY-AUDIT.md`)
|
|
563
|
+
|
|
564
|
+
| # | finding | status now | evidence |
|
|
565
|
+
|---|---|---|---|
|
|
566
|
+
| 1 | node probe leaked credentials; open-mode CSRF | **fixed**; the residual gap for non-browser callers is M1 and M2 | `api.js:797-803`; the collector saw no `Authorization` |
|
|
567
|
+
| 2 | allowlist prefix table | **open by decision**; no bypass found, but see M4 | `allowlist.js:45-61` |
|
|
568
|
+
| 3 | config and settings writes CSRF-exempt in open mode | **partly fixed**: browsers refused; scripts still write (M1) | `server.js:240-256` |
|
|
569
|
+
| 4 | `randomPassword` modulo bias | **fixed** | `users.js:454-462`, `crypto.randomInt` |
|
|
570
|
+
| 5 | session lifetime inverted | **fixed** | `config.js:228-229`; both limits checked in `sessions.js:71-72` |
|
|
571
|
+
| 6 | double KDF on failed sign-in | **mitigated** | sign-in limiter 10 at 0.5/s (`main.js:127`), lockout works |
|
|
572
|
+
| 7 | test disables TLS verification process-wide | **contained**; restored in `finally` | `test/tls.test.js:150`, `test/selfsigned.test.js:85-98` |
|
|
573
|
+
| 8 | `getblock` height unclamped | **fixed** | `api.js:1000-1003` refuses above tip + 1000 |
|
|
574
|
+
| 9 | shared snapshot object | not an issue | unchanged |
|
|
575
|
+
| 10 | audit result not redacted | **fixed** (length is M6) | `main.js:271-279` |
|
|
576
|
+
|
|
577
|
+
### 2026-09-14 (`docs/SECURITY-AUDIT-2026-09-14.md`)
|
|
578
|
+
|
|
579
|
+
| # | finding | status now | evidence |
|
|
580
|
+
|---|---|---|---|
|
|
581
|
+
| M1 | `page` sized an allocation | **fixed** | `store.js:131-140`; `page=999999` returns quickly |
|
|
582
|
+
| M2 | post-tip rows shown as history | **fixed** | `store.js:125-142`, `explorer.js:327,354` |
|
|
583
|
+
| L1 | pool key unescaped | **fixed** | `mining.js:1402` |
|
|
584
|
+
| L2 | transaction cache bounded by count only | **fixed** | `explorer.js:37-44` |
|
|
585
|
+
| L3 | test fixture at a fixed `/tmp` path | **fixed** | `shape-liveness.test.js:23-24` uses `mkdtempSync` |
|
|
586
|
+
| L4 | runtime files not `0600` | **fixed for new files**; old files keep their modes (L10) | `history.js:154,219` |
|
|
587
|
+
| I1 | open-mode config save aims the cookie | **open by decision; reproduced** (M1) | save, restart, cookie sent three times |
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## Things done right
|
|
592
|
+
|
|
593
|
+
- **Defaults:** loopback bind, accounts on, HTTPS with a generated certificate, market polling off,
|
|
594
|
+
node writes off and refused without an identity; a fatal config error for open mode with writes on.
|
|
595
|
+
- **Headers:** strict CSP with a per-response nonce and no inline script or style, enforced by a
|
|
596
|
+
test; frame-busting set twice; HSTS only over TLS.
|
|
597
|
+
- **Authentication:** scrypt with per-user parameters and transparent rehash, equal-cost decoy for
|
|
598
|
+
unknown users, session tokens stored hashed, `HttpOnly` and `SameSite=Strict` cookies, `Secure`
|
|
599
|
+
forced under TLS, double-submit CSRF compared in constant time and refused from the cookie alone.
|
|
600
|
+
- **Open mode's ceiling:** the anonymous user is a frozen viewer on both authentication branches;
|
|
601
|
+
user administration, the audit trail and password changes stay closed.
|
|
602
|
+
- **RPC allowlist:** default deny with exact and prefix deny lists ahead of the allow list; case,
|
|
603
|
+
whitespace and batch tricks all refused.
|
|
604
|
+
- **File serving:** lexical plus `realpath` containment for static files; a strict 8.3 name pattern,
|
|
605
|
+
a fixed game list and a no-symlink directory match for game files.
|
|
606
|
+
- **Secrets:** redaction by shape in logs and the audit trail; no credential in any API response;
|
|
607
|
+
secrets and TLS key `0600`; atomic writes with `fsync` and rename; a clean git history, guarded by a
|
|
608
|
+
privacy test.
|
|
609
|
+
- **Parsing:** the undo decoders are held to "throw or decode exactly" by seeded property tests, with
|
|
610
|
+
exact-width VARINTs, a BigInt amount path and bounds-checked script reads. Fuzzing found no hang.
|
|
611
|
+
- **The build journal:** records only what is proven, checks every bucket's CRC before sorting,
|
|
612
|
+
deletes inputs only after the journal records the sort, and discards itself on any identity
|
|
613
|
+
mismatch or reorganisation.
|
|
614
|
+
- **Front end:** consistent escaping of every peer-, miner- and log-derived string traced; stored
|
|
615
|
+
Display settings are normalised through allowlists and colour patterns before use, so they cannot
|
|
616
|
+
become markup; games load through a fixed import table; no `eval`, `new Function` or string timers.
|
|
617
|
+
- **Supply chain:** zero dependencies, no install step in CI, a read-only workflow token.
|
|
618
|
+
|
|
619
|
+
## Recommendations, in priority order
|
|
620
|
+
|
|
621
|
+
All eight items are done (see each finding's FIXED note).
|
|
622
|
+
|
|
623
|
+
1. **H1 and M5 together.** Drop backpressured stream clients, cap streams per address, and restore a
|
|
624
|
+
body deadline for everything except the stream. One area of `server/http/`.
|
|
625
|
+
2. **Close open mode to scripts (M1, M2).** Refuse config writes and the probe in open mode unless
|
|
626
|
+
the caller is on loopback, strip credentials when the RPC host changes, and stop echoing probe
|
|
627
|
+
response bodies. Running with accounts on, the shipped default, closes these already.
|
|
628
|
+
3. **M3.** Never remove the index directory itself; use a marker file and delete only the index's own files.
|
|
629
|
+
4. **M4.** Deny wallet RPCs by name and correct `docs/SECURITY.md`.
|
|
630
|
+
5. **M7** before the next publish: fix the `files` list and add the pack test.
|
|
631
|
+
6. **M6.** Clamp free-form audit fields.
|
|
632
|
+
7. **M8.** Harden the shipped unit.
|
|
633
|
+
8. The Lows, starting with L2/L3 (escape in `kv()`), L4 (the coinbase parser), L1 (the redirect) and L7
|
|
634
|
+
(the height table), which are each a few lines.
|
|
635
|
+
|
|
636
|
+
## Scope and limits
|
|
637
|
+
|
|
638
|
+
- **Reviewed:** every file under `server/`; every module under `public/js`, with each of about 90
|
|
639
|
+
`innerHTML`, `outerHTML` and `insertAdjacentHTML` sinks read individually; `public/index.html`;
|
|
640
|
+
`scripts/`; `systemd/`; `.github/workflows/`; `package.json` and the packed file list; `.gitignore`.
|
|
641
|
+
- **Dynamic:** throwaway instances with auth on and off, fake and hostile RPC nodes, a headless browser
|
|
642
|
+
against a throwaway instance.
|
|
643
|
+
- **Not covered:** the Bitcoin Core nodes themselves; the configuration of the development machine
|
|
644
|
+
the audit ran on, which is not a secure install and is not reported; the DOS emulator's x86 core as an
|
|
645
|
+
attack surface beyond confirming it runs in the browser with no `eval` or WebAssembly.
|
|
646
|
+
- Per the project's privacy rule, no host name, account name, address, credential value or real peer
|
|
647
|
+
address appears in this report.
|