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.
Files changed (71) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +13 -11
  3. package/SECURITY.md +2 -2
  4. package/docs/API.md +1 -1
  5. package/docs/ARCHITECTURE.md +36 -5
  6. package/docs/CONFIGURATION.md +6 -4
  7. package/docs/DEFECTS.md +4 -1
  8. package/docs/GETTING-STARTED.md +14 -7
  9. package/docs/INSTALL.md +7 -4
  10. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  11. package/docs/PLAN-SKIES.md +142 -0
  12. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  13. package/docs/SECURITY.md +26 -7
  14. package/docs/TROUBLESHOOTING.md +10 -5
  15. package/docs/USER-GUIDE.md +239 -9
  16. package/package.json +4 -2
  17. package/public/css/app.css +87 -0
  18. package/public/index.html +58 -6
  19. package/public/js/app.js +60 -19
  20. package/public/js/blockanoid.js +15 -7
  21. package/public/js/blockout.js +15 -7
  22. package/public/js/blockscene3d.js +51 -11
  23. package/public/js/depthchart.js +1 -1
  24. package/public/js/details3d.js +25 -2
  25. package/public/js/explorer.js +7 -1
  26. package/public/js/livingsky.js +494 -0
  27. package/public/js/login.js +3 -2
  28. package/public/js/mining.js +4 -4
  29. package/public/js/panels.js +27 -18
  30. package/public/js/safenext.js +14 -0
  31. package/public/js/scorched.js +1051 -0
  32. package/public/js/scorchedai.js +227 -0
  33. package/public/js/scorchedair.js +286 -0
  34. package/public/js/scorchedfx.js +376 -0
  35. package/public/js/scorchedshop.js +105 -0
  36. package/public/js/scorchedwind.js +69 -0
  37. package/public/js/scorchedyard.js +1338 -0
  38. package/public/js/settings.js +266 -80
  39. package/public/js/tetrust.js +15 -6
  40. package/public/js/tetsound.js +35 -5
  41. package/scripts/check.js +46 -0
  42. package/scripts/index-build.js +9 -2
  43. package/scripts/pool-map.js +152 -36
  44. package/scripts/setup.js +108 -10
  45. package/scripts/shots.mjs +21 -0
  46. package/scripts/smoke.sh +6 -5
  47. package/scripts/ui.js +4 -2
  48. package/server/auth/sessions.js +33 -13
  49. package/server/chain/blockfile.js +64 -5
  50. package/server/chain/index/build.js +432 -56
  51. package/server/chain/index/heights.js +29 -3
  52. package/server/chain/index/live.js +13 -7
  53. package/server/chain/index/rows.js +6 -1
  54. package/server/chain/index/store.js +28 -5
  55. package/server/chain/index/worker.js +23 -11
  56. package/server/collect/logparse.js +65 -18
  57. package/server/collect/markets.js +76 -7
  58. package/server/collect/mining.js +32 -0
  59. package/server/collect/monitor.js +24 -11
  60. package/server/collect/network.js +19 -9
  61. package/server/config.js +7 -0
  62. package/server/http/api.js +70 -13
  63. package/server/http/server.js +22 -5
  64. package/server/http/sse.js +53 -7
  65. package/server/main.js +13 -3
  66. package/server/rpc/allowlist.js +26 -0
  67. package/server/rpc/client.js +30 -2
  68. package/server/store/audit.js +6 -1
  69. package/server/store/history.js +19 -3
  70. package/server/store/ledger.js +15 -4
  71. 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.