The frontend build pulled in crypto-browserify, stream-browserify and
vm-browserify (via tsconfig paths) only because otplib's
@otplib/plugin-crypto requires Node's crypto. That chain carried the
last production npm audit findings - the elliptic advisory
(GHSA-848j-6mx2-7j84, no fixed release) plus browserify-sign/create-ecdh
(issue #1634, item 3).
The two-factor-auth settings dialog is the only browser consumer of
otplib. It now uses a small WebCrypto TOTP service
(src/app/shared/services/totp.service.ts, RFC 6238: HMAC-SHA1, 6 digits,
30s step) instead, so otplib is no longer bundled and the three
polyfills plus their tsconfig path mappings are removed.
The backend still verifies login tokens with otplib, so the new service
must match it exactly - verified byte-for-byte against otplib and the
RFC 6238 test vectors (generateSecret/keyuri/generate/check parity).
Existing authenticator enrollments keep working. token check() is now
async (WebCrypto's digest is promise-based); the dialog's verify handler
was updated to match, and the value was never used for control flow.
Production npm audit now reports zero vulnerabilities (from 13, incl. 2
critical, at the start of this cleanup series). Verified on the docker
fixture: enrolled a 2FA secret from the new service, confirmed the
backend otplib accepts a token it produces at login, rejected
wrong/absent tokens. Unit spec covers RFC 6238 vectors, keyuri parity
and base32 round-trip; both API suites and the full frontend suite (204
specs) pass.
Session-bound tokens broke re-login after logout: logoutUser destroys
the session, but the SPA navigated to the login page without a
document reload, so the surviving _csrf/XSRF-TOKEN cookies stayed
bound to the destroyed session id and the next login POST failed with
403 until a manual refresh. Hit both manual logout and the idle-timer
auto-logout.
Two coordinated fixes:
1. Frontend: the logout effect now performs a full document navigation
to the login page (after the server logout completes, so the
request is not aborted by the reload), which re-runs the handshake
and mints a token bound to the fresh session. The logout reason
previously travelled on the NgRx action stream, which cannot
survive a reload - it is now handed over via sessionStorage (set
after clearAll) and picked up and cleared by the login component.
The SSO branch is unchanged (it already left the document).
2. Backend: the EBADCSRFTOKEN error path now re-mints the token for
the current session before responding 403, so any client holding a
stale token (e.g. after a server restart rotates the boot secret)
self-heals on retry instead of looping on 403.
Verified on the fixture: reviewer's repro now shows login 200 ->
logout 200 -> stale-token login 403 (binding intact) with re-minted
cookies on the 403 -> retry 200; and the reload path (fresh GET /
after logout, what the full navigation does) logs in on the first
attempt. Both API suites, the CSRF battery, rtl.effects specs and the
full frontend suite pass; frontend and backend artifacts rebuilt.
csurf has been deprecated since 2022 and pins an old cookie release
with a known advisory; npm's only fix is a downgrade (issue #1634,
item 2). csrf-csrf v4 implements the same double-submit-cookie pattern
with an HMAC-signed, session-bound token keyed on the existing boot
secret (common.secret_key).
The frontend contract is unchanged: the token still arrives via the
XSRF-TOKEN cookie/header and is echoed as x-xsrf-token (all token
sources csurf accepted are still read), the signed cookie keeps the
_csrf name (now httpOnly, secure:false to match the session cookie on
plain-HTTP deployments), doubleCsrfProtection attaches req.csrfToken
so app.ts keeps working, and the error code is EBADCSRFTOKEN - already
handled in app.ts. The websocket upgrade check in authCheck.ts now
routes through the shared middleware; upgrade requests are GETs, so
its pass-through semantics are unchanged.
One fix this surfaced: app.ts called req.csrfToken() twice (cookie and
header). Under csurf every token validated against a stable secret;
under csrf-csrf each first-visit call mints a new token, desyncing the
XSRF-TOKEN cookie from the _csrf cookie it must equal. The token is
now generated once per request.
Tokens are session-bound, so a token stolen from one session no longer
validates in another - a check csurf's cookie mode did not perform.
Production npm audit drops from 6 low findings to 4, all in the
crypto-browserify/elliptic chain tracked in #1634.
Verified against the docker regtest fixture: both API suites (43
checks across LND, CLN and Eclair) plus a dedicated CSRF battery -
valid-token auth, missing token 403, garbage token 403, cross-session
replay 403, token stability across requests, the XSRF-TOKEN response
header for Quickpay, and the websocket handshake. Lint and build are
clean.
The transport timeout added for the sendPayment race was derived from
req.body.timeout_seconds, which was only guarded by "|| 600": a
non-numeric value became NaN, which axios treats as no timeout,
silently dropping the transport ceiling for this endpoint.
timeout_seconds is now coerced to a positive finite number (falling
back to 600), which also normalizes the value sent to LND. Large
values are intentionally not capped: the transport bound must stay
above LND's own timeout_seconds bound or the race the margin fixes
would return.
Verified on the fixture: a payment sent with timeout_seconds "abc"
falls back to 600 and completes; the write suite re-passes.
sendPayment's timeout_seconds defaults to 600, equal to the wrapper's
600 s transport bound, so the transport timer (started first) would
win the race and surface ECONNABORTED instead of LND's clean
FAILURE_REASON_* result. The call now passes timeout_seconds plus a
60 s margin as the per-call transport timeout, so LND's mapped failure
always arrives first while the transport stays bounded for the actual
hang case (and a user-supplied timeout_seconds scales the bound with
it).
paymentLookup (/v2/router/track) deliberately keeps the 10-minute
default: it holds a browser-facing response open while tracking, and
payments in flight longer than that are delivered by the websocket
subscription path instead. Documented at the call site.
Verified on the fixture: a routed payment with an explicit
timeout_seconds succeeds; an unroutable payment returns LND's mapped
failure reason (Insufficient Balance - no ECONNABORTED, no transport
timeout message); paymentLookup returns the final state of a settled
payment. Both API suites re-pass.
LND's DELETE /v1/channels/{channelPoint} streams until the closing tx
confirms, routinely longer than the wrapper's 10-minute bound. The
close call in closeChannel is fire-and-forget (202 returned
immediately, no .catch), so the timeout rejection would have become an
unhandled promise rejection and crashed the process ~10 minutes after
any close that had not yet confirmed. request-promise returned
Bluebird promises whose unhandled rejections only warned, which is why
this never crashed before.
The close now uses a copy of the options with timeout: 0 (same
treatment as the invoice/payment subscriptions) and a .catch that logs
through handleError - errors were never surfaced to the HTTP response
anyway, but logging beats Bluebird's silent warning. This was the only
call site without a rejection handler.
Verified on the fixture: opened a disposable 200k alice->bob channel
via RTL, closed it (202, gone from open and listed in closed after
mining), then requested a close for a bogus channel point - LND
rejects the stream, the catch logs the error (no auth headers in it),
and the process stays up. Read suite re-passes.
The 10-minute timeout added for review feedback would have aborted
LND's long-poll subscription streams (/v2/invoices/subscribe and
/v2/router/track), which legitimately stay open until an invoice
settles or a payment resolves - breaking real-time notifications for
any invoice paid more than 10 minutes after creation.
The wrapper now honors a per-call options.timeout (0 disables the
bound, axios semantics; the 10-minute default still applies everywhere
else), and both subscription calls pass timeout: 0. They also copy the
options object instead of mutating it: addInvoice hands the
session-cached options to subscribeToInvoice, so setting the timeout
in place would have leaked an unbounded timeout to every subsequent
request for that node (getOptions resets form/body/qs but not
timeout).
Verified on the regtest fixture: with a websocket client connected as
alice's frontend, creating an invoice opens the subscription stream,
it survives idle, and paying it from the CLN node delivers the SETTLED
event over the websocket in real time. The per-call override was also
verified directly (timeout: 1000 aborts a slow upstream with
ECONNABORTED; timeout: 0 waits it out). Both API suites (31 read + 12
write checks) re-pass.
Review feedback on #1638 flagged two issues in the wrapper:
1. Array form values (eclair's ignoreNodeIds on findroutebetweennodes)
encoded as "ignoreNodeIds=a,b" via String(), and worse, an empty
array produced "ignoreNodeIds=" which Eclair's pubkey list parser
rejects - breaking the default findroute path that worked under
request-promise (qs omitted empty arrays). Arrays are now omitted
when empty and comma-joined when not, matching Eclair's CsvSeq list
format. Verified against the fixture: route eclair->bob->carol is
found with an empty ignore list and disappears when bob is ignored.
Under request-promise's qs indexed encoding (ignoreNodeIds[0]=...)
Eclair never matched the field name, so the ignore list was silently
dropped; this change makes it effective for the first time.
2. The shared transport had no request timeout, so a hung upstream
held connections open indefinitely. Added a 10-minute bound,
sized to the slowest legitimate operations (LND's /v2/router/send
streams up to timeout_seconds=600; slow CLN channel operations get
req.setTimeout(600000) upstream).
Both API suites (31 read + 12 write checks) re-pass on the fixture.
request has been deprecated since 2020 with an unfixed SSRF advisory and
pins vulnerable copies of form-data (critical), qs, tough-cookie and
uuid - 8 of the 13 remaining production audit findings, none fixable by
version bumps (issue #1634, item 1).
All 36 backend files that imported request-promise now use a small
compatibility wrapper (server/utils/request.ts) backed by axios, which
is already a production dependency. The wrapper accepts the existing
options shape (qs, form - object or pre-encoded string, body,
baseUrl/uri, rejectUnauthorized, json), resolves with the response body
directly, and rejects with a plain object mirroring request-promise's
StatusCodeError/RequestError shape, so CommonService.handleError works
unchanged (ECONNREFUSED -> 503, Eclair StatusCodeError -> 500, nested
error body extraction). Auth headers are excluded from rejected errors
so they cannot leak into logs. Callers without json: true (block
explorer, currency rates) still get raw text bodies, and LND's
line-delimited /v2/router/send stream still surfaces as a string for
the existing parser.
Only behavioral code change: CLN verifyMessage used request-promise's
callback style and was ported to the same promise style as signMessage;
four Eclair handlers gained explicit returns to satisfy
noImplicitReturns once the import became typed.
Production npm audit drops from 13 findings (2 critical) to 6 low, all
in the crypto-browserify/elliptic chain tracked in #1634.
Verified against the docker regtest fixture with 43 API checks across
LND, Core Lightning and Eclair: reads, invoice creation, a routed LND
payment over the streaming endpoint, cross-implementation payments from
CLN and Eclair, message sign/verify, channel backup to disk, and
bad-invoice/node-unreachable error mapping. Lint and both production
builds are clean.
Per LND v0.21.0 release notes, the sat_per_byte option will be removed
in v0.22 across CloseChannel, OpenChannel, SendCoins, SendMany, and
walletrpc.BumpFee. LND already treats sat_per_byte as sat/vbyte
internally, so this is a pure rename with no value conversion. Updates
both the wire-format strings sent to LND and the matching TypeScript
identifiers across the close-channel, open-channel, send-coins, and
bump-fee paths.
The a11y fix in #1609 wrapped the bare Scroll Range mat-select in a
mat-form-field for its label, but the wrapper reserved subscript space
(78.8px vs the date field 56px) and anchored to the row top, leaving
the date picker ~11px lower on every implementation reports screen.
Use subscriptSizing="dynamic" (no hints are used) and center on the
cross axis, restoring the aligned 56px row from v0.15.8 while keeping
the label. Verified headlessly against the regtest fixture: both
fields now render at identical top/height.
Fixes#1635
The fix is within the pinned 0.3.x line but the exact pin kept npm
update from reaching it. Clears the last high-severity production
vulnerability; frontend build and full spec suite verified.
Apply the bumps from all 20 open Dependabot security PRs (#1583-#1617)
in one pass on the release branch: axios 1.16.0, ws 8.21.0, the
socket.io server stack, express path-to-regexp, follow-redirects,
lodash and the remaining flagged transitive deps. Angular framework
packages move in lockstep to 20.3.26 and the CLI/build toolchain to
20.3.32, which drops the vulnerable node-forge from the tree entirely.
Also pick up in-range fixes without open PRs (qs, uuid, tough-cookie,
cookie, ajv, bn.js, elliptic, socket.io-parser).
npm audit: 85 vulnerabilities (23 prod) -> 30 (14 prod). The remainder
(request/request-promise, csurf, pdfmake, crypto-browserify chain)
needs code changes, not bumps, and is tracked separately.
Verified: lint, 199 frontend specs, backend + frontend production
builds, and an end-to-end smoke test against the docker regtest
fixture (LND, CLN and Eclair auth/getinfo/channels + WS upgrade).
Dependabot PRs target master and are resolved in batch PRs against the
release branch, but the process was undocumented. Add a "Handling
Dependabot PRs" section covering target collection, pinned in-range
fixes, Angular lockstep, from-scratch lockfile regeneration, artifact
rebuild, verification, and issue-tracking for deprecated packages.
Mermaid flowchart (rendered by GitHub) showing the channel graph, each
node's chain-backend link -- including eclair's dedicated wallet and
hashblock ZMQ endpoint -- and the protocol RTL uses to reach each node.
Completes backend coverage of RTL's three implementations in the docker/
dev fixture: an eclair node (polarlightning/eclair 0.13.1) joins the three
LND nodes and the CLN node, wired to RTL over its HTTP API with basic auth,
and the seed opens an eclair->bob channel (3.5M sats, 1M pushed), sends two
direct payments and leaves one open invoice.
Non-obvious plumbing this needed:
- polarlightning/eclair instead of acinq/eclair: the official image is
amd64-only (useless on Apple Silicon) and its newest versioned tag is
years stale; Polar builds the same ACINQ source multi-arch.
- Eclair has no on-chain wallet of its own -- it drives a bitcoind wallet.
A new eclair-wallet-init container creates a dedicated "eclair" wallet
before the node starts; without it eclair attaches to "the default
loaded wallet", i.e. the rtldev mining wallet.
- bitcoind now also publishes a zmqpubhashblock endpoint (28336): eclair's
bitcoind.zmqblock consumes the hashblock topic, not the rawblock one LND
uses. Wired to rawblock, eclair never sees new blocks and channels hang
in WAIT_FOR_FUNDING_CONFIRMED.
- Eclair confirms channels at 8 blocks (channel.min-depth-blocks), not 6,
and 'open' returns before the funding tx is broadcast -- the seed waits
for the mempool and mines 8 blocks for this channel.
Adds a bin/e-cli helper (eclair-cli with the API password), updates the
README, and verified end-to-end: seed completes, the channel reaches
NORMAL, both payments settle, and RTL's /rtl/api/ecl endpoints return the
node, channel and invoice data.
The #1606 fix added 'channel.connected = !!channel.peer_connected' to
server/controllers/cln/channels.ts but the committed compiled artifact
backend/controllers/cln/channels.js was never regenerated, leaving it stale.
Rebuild it so the committed backend output matches its TypeScript source.
Follow-up to the second #1629 review:
- F4: the limiter invokes its done callback outside the surrounding .then/.catch,
so a throw in the response-send body became an unhandled rejection with no
response (a 500 -> hang regression, notably on LND postPeer where the inner
.catch was removed). Wrap each converted done body in try/catch that sends the
error response, guarded by res.headersSent.
- F5: CLN postPeer re-listed peers but never resolved their aliases, so a freshly
connected CLN peer came back with a raw node id (the frontend uses this response
directly). Resolve aliases through the same bounded limiter, matching LND postPeer.
- F6: make runWithConcurrencyLimit fire 'done' exactly once via a one-shot guard,
so multiple synchronous completions (e.g. non-function task elements) can't
double-send the response.
Follow-up to the #1501 review (PR #1629):
- F1: CLN getAlias now builds its request from selNode.authentication.options
instead of the shared module-level 'options'. That coupling meant a cold
Peers/route lookup dereferenced a null 'options'; with the new limiter
swallowing per-task throws, that returned 200 with every alias unset. Aliases
now resolve regardless of call order, with a truncated-id fallback if auth
options are somehow absent.
- F2: mirror the 20-way concurrency bound to LND peers (getPeers and postPeer),
which had the same unbounded Promise.all alias fan-out. Eclair resolves
aliases inline from a bulk nodes list, so it needs no change.
- F3: normalize runWithConcurrencyLimit's start count to at least 1 so a
non-positive limit can't leave 'done' unfired and hang the response.
RTL resolves peer aliases by calling listnodes once per peer. A prior fix
(1cec7b1) bounded this to 20 concurrent calls plus a cache for the channel
list, but the peers list and route lookup still used an unbounded Promise.all,
firing one request per peer at once. On nodes with many peers this overwhelms
clnrest and fails with 'Resource temporarily unavailable (os error 11)'
(EAGAIN), so aliases fall back to raw node IDs.
- peers.ts and network.ts getRoute now resolve aliases via
runWithConcurrencyLimit(tasks, 20, ...), matching the channel list.
- Harden runWithConcurrencyLimit to call done() immediately for an empty task
list; otherwise an empty peers/route set would never send a response.
- Give the alias cache a 6h TTL and a max size (evicting oldest) so aliases
refresh without an RTL restart and the cache can't grow unbounded.
The accessibility edits dropped the final newline from six form templates.
Add it back so these files end with a newline again (POSIX text-file
convention; keeps diffs clean and avoids no-newline lint noise).
Several PRs were merged onto Release-0.15.9 before the per-PR release-notes
process was established, leaving them undocumented. Add entries for:
- #1581 Fix page-load error when a channel alias is undefined (Bug Fixes)
- #1601 Fix stale auth options blocking a not-yet-ready node (Bug Fixes)
- #1582 Add Disable Authentication option (Enhancements)
- #1621 Rebuild the regtest docker fixture (Developer Tooling)
Blocks-till-maturity is critical information for a force-closing channel but
was only visible in the per-channel detail modal. The column and its data
binding already existed in the pending force-closing table (and was selectable
via column settings); it was just missing from the default column selection.
Add blocks_til_maturity to the pending_force_closing default columnSelection
and columnSelectionSM so it is surfaced on the list by default on both desktop
and mobile.
A dependency-update commit in the 0.15.8-beta cycle mechanically renamed
the paginator binding [showFirstLastButtons] to [hidePageSize] on every
mat-paginator while keeping the same 'screenSize === XS ? false : true'
expression. The two properties have opposite polarity, so this inverted
the behavior: on desktop the page-size selector was hidden (locking users
to 10 items per page) and the first/last-page buttons were dropped as
collateral. Revert the ~44 affected paginators back to [showFirstLastButtons]
across the LND, CLN, Eclair and shared tables.
Address review F7/F8 on #1625:
F7 (verification): the onchain.ts `connected === false` branch reads /v1/listfunds
(CLN's own connected field) and only buckets balance as inactive — it is not the
listPeerChannels mirror and does no close logic, so the coercion activates nothing
there. Reword the mirror comment, which inaccurately implied onchain.ts consumes it;
the mirror simply keeps the documented backward-compat `connected` field defined.
F8: hardcode the rune path in create-rune.sh to /root/.lightning/rtl.rune so it
matches the volume mount, healthcheck and RTL runePath instead of deriving it from
${LIGHTNINGD_DATA}, removing the silent-divergence risk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review F6 on #1625: the LND channel information modal has the same
unguarded selNode.settings.blockExplorerUrl binding as the CLN one, and it is
opened without selNode from the active-HTLCs and channel-backup tables, so it can
blank out the same way. Guard the explorer link (*ngIf + a no-op click when the
url is absent) so a missing selNode can no longer blank the dialog. Eclair's modal
doesn't use selNode.settings, so it needs no change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review F5 on #1625: create-rune.sh was a one-shot poststart script — if
the RPC wasn't ready within its poll or createrune failed, it exited without ever
writing rtl.rune, and since the cln healthcheck gates on that file and rtl waits
on service_healthy, a failed pass deadlocked the whole stack until 'down -v'.
Drive rune creation from the healthcheck instead: the script is now a quick,
idempotent single attempt, and the healthcheck runs it on every interval, so a
transient RPC-startup race just retries and self-heals. Moved the script out of
lightning-poststart.d to /opt and updated the healthcheck, compose comment and
README accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review F4 on #1625: the new selNode field resolved to the global DOM
Node type because the RTL Node model was not imported. Import Node from
shared/models/RTLconfig so the field, the rootSelectedNode store value, and the
CLNChannelInformationComponent it feeds all agree, restoring type-checking.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The channel information modal renders a block-explorer link from
selNode.settings.blockExplorerUrl, but the pending/inactive channels table
opened the modal without passing selNode. With it undefined, that binding threw
during change detection and blanked every field below it — State, Connected,
Private and the balances all rendered without a value. A disconnected channel
moves to the pending/inactive table, so this is what surfaced on View Info for a
disconnected channel (the symptom in the original report).
Pass selNode from the pending table (matching the open table), and guard the
modal's explorer link (*ngIf + a no-op click when the url is absent) so a missing
selNode can no longer blank the whole dialog. Add a regression test asserting the
pending table passes selNode when opening the modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fixture had only bitcoind + three LND nodes, so RTL's Core Lightning screens
had no backend to exercise. Add a `cln` node (official elementsproject/lightningd
image, multi-arch) wired to RTL over clnrest with rune auth, and have the seed
open a cln->alice channel so the CLN channel/peer screens have real data.
- docker-compose.yml: cln service (clnrest on 0.0.0.0:3010, https), a healthcheck
gated on the rune file so rtl waits for it, and rtl now mounts the cln volume
read-only and depends on cln being healthy. The rtl image is parameterized via
${RTL_IMAGE:-...} so an unreleased build can be tested against the fixture.
- cln/poststart.d/create-rune.sh: once the RPC is up, create a master rune and
write it as LIGHTNING_RUNE="..." where RTL reads it (runePath). Polls for RPC
readiness because the image entrypoint can invoke poststart before the socket
exists.
- RTL-Config.regtest.json: add node index 4 (CLN, rune auth, https://cln:3010).
- seed.sh: fund cln, connect to alice, open a 4,000,000 sat channel, wait active.
- README + release notes updated.
Used to verify the CLN channel connection-status fix (#1606) end-to-end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review F3 on #1625: the Close-Channel *ngIf still read the legacy
`connected` field while its neighboring column now reads peer_connected. Point
it at peer_connected directly so it no longer depends on the backend mirror,
removing the latent coupling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review feedback on #1625: copy peer_connected onto the legacy
`connected` field as a real boolean (!!), so strict-equality readers such as
onchain.ts's `connected === false` behave correctly when peer_connected is
absent, instead of leaving `connected` undefined.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>