feat: support client-provided session credentials#2
Conversation
POST /lnurl/session now accepts an optional JSON body with { sessionId, token }.
When provided, the server uses these instead of generating random ones.
Same sessionId always produces the same LNURL, enabling wallets to advertise
a stable payment endpoint. If a session with the provided ID already exists
(e.g. stale connection), the old one is torn down and replaced.
Validation: sessionId must be >= 16 chars, token must be >= 32 chars.
There was a problem hiding this comment.
🔴 CRITICAL: Session hijacking via public sessionId — request changes
Finding 1 — Session takeover (CRITICAL, money-losing bug)
src/session-manager.ts:25-28
if (this.sessions.has(id)) {
this.destroy(id);
}The sessionId is embedded in the LNURL callback URL in plaintext (/lnurl/{sessionId} — see server.ts:94). Every payer who receives the LNURL can decode bech32 and extract the sessionId. This is by design for the LNURL protocol.
The attack: an attacker who decodes the LNURL calls POST /lnurl/session with the victim's sessionId and their own token. The server:
- Destroys the legitimate wallet's SSE session (and any pending invoice)
- Creates a new session with the attacker's token
- The attacker now controls the session — when the next payer pays, the attacker provides their own Lightning invoice
This is an invoice substitution attack. The attacker steals payments meant for the victim.
Fix: On reconnect, the provided token MUST match the existing session's token before tearing it down:
if (this.sessions.has(id)) {
const existing = this.sessions.get(id)!;
if (existing.token !== token) {
// Return an error — caller doesn't own this session
return null; // or throw
}
this.destroy(id);
}And in server.ts, handle the null/error by returning 403.
Finding 2 — Asymmetric credential provision allows confusing states
src/session-manager.ts:22-23
const id = providedId || randomBytes(16).toString("hex");
const token = providedToken || randomBytes(32).toString("hex");A client can provide sessionId without token (or vice versa). This creates a session with a deterministic ID but a random token — meaning the wallet can never reconnect with the same token. The "stable endpoint" feature silently degrades to broken behavior.
Fix: In server.ts, validate that either both are provided or neither:
if ((providedId == null) !== (providedToken == null)) {
res.status(400).json({ error: "sessionId and token must both be provided or both omitted" });
return;
}Finding 3 — No hex format validation
src/server.ts:78-84
The PR description says sessionId and token should be "hex strings", and the companion wallet PR (arkade-os/wallet#559) derives them via SHA-256 (which produces hex). But the server only validates length, not format.
A non-hex sessionId with URL-special characters (e.g., /, ?, #, %) could break URL routing or cause ambiguous path matching in GET /lnurl/:id and GET /lnurl/:id/callback.
Fix: Add hex validation:
const HEX_RE = /^[0-9a-f]+$/i;
if (providedId != null && !HEX_RE.test(providedId)) {
res.status(400).json({ error: "sessionId must be a hex string" });
return;
}Finding 4 — destroy() on reconnect drops pending invoices silently
src/session-manager.ts:25-28 (after fixing Finding 1)
When a legitimate wallet reconnects, destroy() rejects any pendingInvoice with "Session closed". The payer gets an error even though the wallet is still alive — it just reconnected. Consider draining the pending invoice to the new session instead of rejecting it, or at minimum document this as expected behavior.
Finding 5 — Missing test: attacker token on existing session
The test suite covers the happy path (reconnect with same creds) but doesn't test the adversarial case: reconnecting with the same sessionId but a different token. After fixing Finding 1, add a test that verifies this returns 403 and the original session is NOT destroyed.
Non-blocking notes
package-lock.json(3891 lines added): This repo uses pnpm perenginesin package.json. Should this bepnpm-lock.yamlinstead? Apackage-lock.jsoncommitted alongside pnpm can cause confusion.- The 6 new tests are well-structured and cover the main happy paths. Good work there.
- The SSE teardown-on-reconnect design is sound once the token check is added.
Bottom line: Finding 1 is a money-losing vulnerability that must be fixed before merge. An attacker with the public LNURL can hijack any reusable session. Findings 2 and 3 are lower severity but should also be addressed.
- Reject reconnect attempts with wrong token (prevents session hijack via public sessionId extracted from LNURL) - Require both sessionId and token or neither (no asymmetric provision) - Validate hex format on sessionId and token (prevents URL routing issues) - Remove accidental package-lock.json (repo uses pnpm) - Add 5 new tests: hijack attempt, non-hex values, asymmetric credentials
The client now sends only { token } — the server computes
sessionId = SHA-256(token).slice(0, 32). This eliminates the
possibility of mismatched sessionId/token pairs and makes session
hijack impossible by construction (different tokens always produce
different sessionIds).
🟡 Re-review after
|
Summary
tokenwhen creating a session viaPOST /lnurl/sessionsessionId = SHA-256(token).slice(0, 32)— same token always produces the same LNURLHow it works
Clients can optionally send a JSON body with
POST /lnurl/session:{ "token": "a-deterministic-hex-string-at-least-32-chars" }If omitted, the server generates random credentials as before (fully backward compatible).
The server derives
sessionIdfrom the token usingSHA-256(tokenBytes).slice(0, 32). The wallet never sends a sessionId — only the token.Validation:
tokenmust be a hex string >= 32 charactersCompanion PR
Test plan