From 02e83f7603f4641cea73549f581e3fba6c99b53b Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 10 Jun 2026 10:08:49 +0200 Subject: [PATCH 1/3] fix(lightning): block path traversal in LNURL forwarding endpoints (BUG-1209) Validate link IDs in LightningClient to reject path traversal via double-encoded slashes. Add admin endpoint to rotate webhook secrets. --- src/integration/lightning/lightning-client.ts | 16 +++++++++++++ .../generic/admin/admin.controller.ts | 10 ++++++++ src/subdomains/generic/admin/admin.module.ts | 2 ++ .../__tests__/lnurl-forward.spec.ts | 24 +++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/src/integration/lightning/lightning-client.ts b/src/integration/lightning/lightning-client.ts index 0c3e1053ca..4520a5ad87 100644 --- a/src/integration/lightning/lightning-client.ts +++ b/src/integration/lightning/lightning-client.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { Agent } from 'https'; import { Config } from 'src/config/config'; @@ -197,11 +198,15 @@ export class LightningClient implements CoinOnly { // --- LNURLp REWRITE --- // async getLnurlpPaymentRequest(linkId: string): Promise { + this.validateLinkId(linkId); + const lnBitsUrl = `${Config.blockchain.lightning.lnbits.lnurlpUrl}/${linkId}`; return this.http.get(lnBitsUrl, this.httpLnBitsConfig()); } async getLnurlpInvoice(linkId: string, params: any): Promise { + this.validateLinkId(linkId); + const lnBitsCallbackUrl = `${Config.blockchain.lightning.lnbits.lnurlpApiUrl}/lnurl/cb/${linkId}`; return this.http.get(lnBitsCallbackUrl, this.httpLnBitsConfig(params)); } @@ -289,6 +294,8 @@ export class LightningClient implements CoinOnly { } async getLnurlwLink(linkId: string): Promise { + this.validateLinkId(linkId); + return this.http.get( `${Config.blockchain.lightning.lnbits.lnurlwApiUrl}/links/${linkId}`, this.httpLnBitsConfig(), @@ -328,11 +335,16 @@ export class LightningClient implements CoinOnly { // --- LNURLd --- // async getLnurlDevice(id: string, params: any): Promise { + this.validateLinkId(id); + const url = `${this.getDeviceUrl()}/${id}`; return this.http.get(url, this.httpLnBitsConfig(params)); } async getLnurlDeviceCallback(id: string, variable: string, params: any): Promise { + this.validateLinkId(id); + this.validateLinkId(variable); + const url = `${this.getDeviceUrl()}/cb/${id}/${variable}`; return this.http.get(url, this.httpLnBitsConfig(params)); } @@ -343,6 +355,10 @@ export class LightningClient implements CoinOnly { } // --- HELPER METHODS --- // + private validateLinkId(linkId: string): void { + if (!/^[\w-]+$/.test(linkId)) throw new BadRequestException('Invalid link id'); + } + private httpLnBitsConfig(params?: any): HttpRequestConfig { return { params: { 'api-key': Config.blockchain.lightning.lnbits.apiKey, ...params }, diff --git a/src/subdomains/generic/admin/admin.controller.ts b/src/subdomains/generic/admin/admin.controller.ts index 8fe0042d9b..8c0618ba6b 100644 --- a/src/subdomains/generic/admin/admin.controller.ts +++ b/src/subdomains/generic/admin/admin.controller.ts @@ -5,6 +5,7 @@ import { LetterService } from 'src/integration/letter/letter.service'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { DepositService } from 'src/subdomains/supporting/address-pool/deposit/deposit.service'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { AdminService } from './admin.service'; @@ -18,6 +19,7 @@ export class AdminController { private readonly adminService: AdminService, private readonly notificationService: NotificationService, private readonly letterService: LetterService, + private readonly depositService: DepositService, ) {} @Post('mail') @@ -46,4 +48,12 @@ export class AdminController { async payout(@Body() request: PayoutRequestDto): Promise { return this.adminService.payout(request); } + + @Post('lightning/rotate-webhook-secrets') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async rotateLightningWebhookSecrets(): Promise { + return this.depositService.updateLightningDepositWebhook(); + } } diff --git a/src/subdomains/generic/admin/admin.module.ts b/src/subdomains/generic/admin/admin.module.ts index d04256a04c..0478e95534 100644 --- a/src/subdomains/generic/admin/admin.module.ts +++ b/src/subdomains/generic/admin/admin.module.ts @@ -4,6 +4,7 @@ import { SharedModule } from 'src/shared/shared.module'; import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.module'; import { ReferralModule } from 'src/subdomains/core/referral/referral.module'; import { SellCryptoModule } from 'src/subdomains/core/sell-crypto/sell-crypto.module'; +import { AddressPoolModule } from 'src/subdomains/supporting/address-pool/address-pool.module'; import { BankModule } from 'src/subdomains/supporting/bank/bank.module'; import { DexModule } from 'src/subdomains/supporting/dex/dex.module'; import { NotificationModule } from 'src/subdomains/supporting/notification/notification.module'; @@ -26,6 +27,7 @@ import { AdminService } from './admin.service'; PayInModule, DexModule, PayoutModule, + AddressPoolModule, ], controllers: [AdminController], providers: [AdminService], diff --git a/src/subdomains/generic/forwarding/controllers/__tests__/lnurl-forward.spec.ts b/src/subdomains/generic/forwarding/controllers/__tests__/lnurl-forward.spec.ts index c93467f482..b7341ef9e3 100644 --- a/src/subdomains/generic/forwarding/controllers/__tests__/lnurl-forward.spec.ts +++ b/src/subdomains/generic/forwarding/controllers/__tests__/lnurl-forward.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; import { HttpService } from 'src/shared/services/http.service'; @@ -134,4 +135,27 @@ describe('LnurlForward', () => { expect(result).toEqual({ status: 'OK' }); }); }); + + describe('Path traversal prevention', () => { + it.each(['api%2fv1%2flinks', 'api%252fv1%252flinks', '../foo', 'foo/bar', '..%2fwallet'])( + 'rejects malicious lnurlp id: %s', + async (id) => { + await expect(lnurlpForward.lnUrlPForward(id, undefined)).rejects.toThrow(BadRequestException); + }, + ); + + it.each(['api%2fv1%2flinks', '../foo', 'foo/bar'])( + 'rejects malicious lnurlp callback id: %s', + async (id) => { + await expect(lnurlpForward.lnUrlPCallbackForward(id, {})).rejects.toThrow(BadRequestException); + }, + ); + + it.each(['api%2fv1%2flinks', '../foo', 'foo/bar'])( + 'rejects malicious lnurlw id: %s', + async (id) => { + await expect(lnurlwForward.lnUrlWForward(id)).rejects.toThrow(BadRequestException); + }, + ); + }); }); From dc1b6d0fac7d540d46e7b6c10865ac76560510b6 Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 10 Jun 2026 11:16:18 +0200 Subject: [PATCH 2/3] fix(lightning): add validation guards to getLnurlpLink and updateLnurlpLink --- src/integration/lightning/lightning-client.ts | 4 +++- .../__tests__/lnurl-forward.spec.ts | 18 ++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/integration/lightning/lightning-client.ts b/src/integration/lightning/lightning-client.ts index 4520a5ad87..39a1a3b5e2 100644 --- a/src/integration/lightning/lightning-client.ts +++ b/src/integration/lightning/lightning-client.ts @@ -220,6 +220,8 @@ export class LightningClient implements CoinOnly { } async getLnurlpLink(linkId: string): Promise { + this.validateLinkId(linkId); + return this.http.get( `${Config.blockchain.lightning.lnbits.lnurlpApiUrl}/links/${linkId}`, this.httpLnBitsConfig(), @@ -250,7 +252,7 @@ export class LightningClient implements CoinOnly { } async updateLnurlpLink(linkId: string, data: LnurlpLinkUpdateDto): Promise { - if (!linkId) throw new Error('LinkId is undefined'); + this.validateLinkId(linkId); return this.http.put( `${Config.blockchain.lightning.lnbits.lnurlpApiUrl}/links/${linkId}`, diff --git a/src/subdomains/generic/forwarding/controllers/__tests__/lnurl-forward.spec.ts b/src/subdomains/generic/forwarding/controllers/__tests__/lnurl-forward.spec.ts index b7341ef9e3..d6fe50ae0f 100644 --- a/src/subdomains/generic/forwarding/controllers/__tests__/lnurl-forward.spec.ts +++ b/src/subdomains/generic/forwarding/controllers/__tests__/lnurl-forward.spec.ts @@ -144,18 +144,12 @@ describe('LnurlForward', () => { }, ); - it.each(['api%2fv1%2flinks', '../foo', 'foo/bar'])( - 'rejects malicious lnurlp callback id: %s', - async (id) => { - await expect(lnurlpForward.lnUrlPCallbackForward(id, {})).rejects.toThrow(BadRequestException); - }, - ); + it.each(['api%2fv1%2flinks', '../foo', 'foo/bar'])('rejects malicious lnurlp callback id: %s', async (id) => { + await expect(lnurlpForward.lnUrlPCallbackForward(id, {})).rejects.toThrow(BadRequestException); + }); - it.each(['api%2fv1%2flinks', '../foo', 'foo/bar'])( - 'rejects malicious lnurlw id: %s', - async (id) => { - await expect(lnurlwForward.lnUrlWForward(id)).rejects.toThrow(BadRequestException); - }, - ); + it.each(['api%2fv1%2flinks', '../foo', 'foo/bar'])('rejects malicious lnurlw id: %s', async (id) => { + await expect(lnurlwForward.lnUrlWForward(id)).rejects.toThrow(BadRequestException); + }); }); }); From 8250788c0c8578fe5671aad42472b2769d0b2f49 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:51:45 +0200 Subject: [PATCH 3/3] fix(lightning): restore CA-validated https agent for LNbits requests (#3869) The Agent reuse refactoring dropped the CA-validated httpsAgent from httpLnBitsConfig, so LNbits requests are verified against system CAs only. In production LNbits serves the self-signed LND certificate, which makes every LNbits call fail TLS verification. Reuse one shared CA-validated agent for both LND and LNbits requests. --- src/integration/lightning/lightning-client.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/integration/lightning/lightning-client.ts b/src/integration/lightning/lightning-client.ts index 39a1a3b5e2..570132153d 100644 --- a/src/integration/lightning/lightning-client.ts +++ b/src/integration/lightning/lightning-client.ts @@ -27,10 +27,12 @@ import { CoinOnly } from 'src/integration/blockchain/shared/util/blockchain-clie import { LightningHelper } from './lightning-helper'; export class LightningClient implements CoinOnly { - private readonly lndAgent: Agent; + // LND and LNbits both serve the self-signed LND certificate (reached via + // private IP on PRD), so requests must be verified against this CA, not the system CAs + private readonly tlsAgent: Agent; constructor(private readonly http: HttpService) { - this.lndAgent = new Agent({ ca: Config.blockchain.lightning.certificate }); + this.tlsAgent = new Agent({ ca: Config.blockchain.lightning.certificate }); } // --- LND --- // @@ -363,13 +365,14 @@ export class LightningClient implements CoinOnly { private httpLnBitsConfig(params?: any): HttpRequestConfig { return { + httpsAgent: this.tlsAgent, params: { 'api-key': Config.blockchain.lightning.lnbits.apiKey, ...params }, }; } private httpLndConfig(): HttpRequestConfig { return { - httpsAgent: this.lndAgent, + httpsAgent: this.tlsAgent, headers: { 'Grpc-Metadata-macaroon': Config.blockchain.lightning.lnd.adminMacaroon }, }; }