From 008016b947abd2874da1eef1a7ee8d684eae6db2 Mon Sep 17 00:00:00 2001 From: Femi <114834028+Toyosi5566@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:32:36 +0100 Subject: [PATCH 1/2] security: ignore local environment files .env is read by dotenv.config() at boot and holds ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY and DEPLOYER_SECRET_KEY. None of the .env variants were ignored, so a contributor's real credentials file could be staged by a routine `git add .` with nothing to warn anyone. Ignore the .env variants and keep the committed example file trackable. No .env file is tracked at HEAD and no .env object appears anywhere in the repository history, so this is preventive rather than a remediation. --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index bea4619..ac0c2db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,12 @@ coverage/ *.lcov coverage-final.json clover.xml + +# Local environment files. .env is loaded by dotenv at boot and holds live +# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY, +# DEPLOYER_SECRET_KEY). The committed example file is the only environment +# file that belongs in git. +.env +.env.local +.env.*.local +!.env.example From f93d264d4db974610d917fc0778b6a4952266711 Mon Sep 17 00:00:00 2001 From: testimonycontracts Date: Sat, 17 Oct 2026 15:54:24 +0100 Subject: [PATCH 2/2] fix: fix(config): validate the stellar network at boot (#233) --- src/__tests__/stellar.service.test.ts | 48 ++++++++++++++++++++++++++- src/config/env.ts | 31 +++++++++++++++-- src/services/stellar.service.ts | 16 +++++---- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/__tests__/stellar.service.test.ts b/src/__tests__/stellar.service.test.ts index e14b045..e543ced 100644 --- a/src/__tests__/stellar.service.test.ts +++ b/src/__tests__/stellar.service.test.ts @@ -1,4 +1,4 @@ -import { StellarService, InsufficientReserveError } from '../services/stellar.service'; +import { StellarService, InsufficientReserveError, resolveNetwork } from '../services/stellar.service'; import * as StellarSdk from '@stellar/stellar-sdk'; import { config } from '../config/env'; import { decrypt } from '../utils/encryption.util'; @@ -92,6 +92,30 @@ describe('StellarService', () => { mockRedisKeys.mockResolvedValue([]); }); + describe('resolveNetwork', () => { + it('should resolve TESTNET to the testnet horizon and passphrase', () => { + const network = resolveNetwork('TESTNET'); + + expect(network.horizonUrl).toBe('https://horizon-testnet.stellar.org'); + expect(network.passphrase).toBe(StellarSdk.Networks.TESTNET); + }); + + it('should resolve PUBLIC to the public horizon and passphrase', () => { + const network = resolveNetwork('PUBLIC'); + + expect(network.horizonUrl).toBe('https://horizon.stellar.org'); + expect(network.passphrase).toBe(StellarSdk.Networks.PUBLIC); + }); + + it('should reject unsupported network values naming the variable and accepted values', () => { + expect(() => resolveNetwork('testnet')).toThrow(/STELLAR_NETWORK/); + expect(() => resolveNetwork('testnet')).toThrow(/TESTNET/); + expect(() => resolveNetwork('testnet')).toThrow(/PUBLIC/); + expect(() => resolveNetwork('PUBLIC ')).toThrow(/STELLAR_NETWORK/); + expect(() => resolveNetwork('STAGING')).toThrow(/STELLAR_NETWORK/); + }); + }); + describe('generateWallet', () => { it('should return a generated keypair with encrypted raw seed', () => { const wallet = stellarService.generateWallet(); @@ -115,6 +139,13 @@ describe('StellarService', () => { expect(StellarSdk.Horizon.Server).toHaveBeenCalledWith('https://horizon.stellar.org'); }); + + it('should use the testnet horizon when configured for testnet', () => { + config.STELLAR_NETWORK = 'TESTNET'; + new StellarService(); + + expect(StellarSdk.Horizon.Server).toHaveBeenCalledWith('https://horizon-testnet.stellar.org'); + }); }); describe('fundTestnetAccount', () => { @@ -261,6 +292,21 @@ describe('StellarService', () => { ); }); + it('should use the testnet network passphrase when configured for testnet', async () => { + config.STELLAR_NETWORK = 'TESTNET'; + stellarService = new StellarService(); + + const validPublicKey = 'GBBM6BKZPEHWPI3VK3VNKEJEXTMIGNNCE2ZEXSVEEKSJNDYTK2E4QUDE'; + await stellarService.sendPayment(mockSeed, validPublicKey, '10.0'); + + expect(StellarSdk.TransactionBuilder).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + networkPassphrase: StellarSdk.Networks.TESTNET, + }), + ); + }); + it('should invalidate redis cache after sendPayment', async () => { mockRedisScan.mockResolvedValueOnce(['0', ['tx_history:G_MOCK:page:1']]); mockRedisScan.mockResolvedValueOnce(['0', ['tx_history:GBBM6BKZPEHWPI3VK3VNKEJEXTMIGNNCE2ZEXSVEEKSJNDYTK2E4QUDE:page:1']]); diff --git a/src/config/env.ts b/src/config/env.ts index 7d4b374..9faa387 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -13,6 +13,30 @@ if (!process.env.JWT_SECRET) { throw new Error('JWT_SECRET environment variable is required'); } +export const SUPPORTED_STELLAR_NETWORKS = ['TESTNET', 'PUBLIC'] as const; +export type StellarNetwork = typeof SUPPORTED_STELLAR_NETWORKS[Number]; + +const rawStellarNetwork = process.env.STELLAR_NETWORK || 'TESTNET'; +if (!SUPPORTED_STELLAR_NETWORKS.includes(rawStellarNetwork as StellarNetwork)) { + throw new Error( + `STELLAR_NETWORK must be one of ${SUPPORTED_STELLAR_NETWORKS.join(', ')} (got "${rawStellarNetwork}")` + ); +} +const stellarNetwork = rawStellarNetwork as StellarNetwork; + +export const STELLAR_NETWORK_CONFIG = { + TESTNET: { + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + }, + PUBLIC: { + horizonUrl: 'https://horizon.stellar.org', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + }, +} as const; + +export const stellarNetworkConfig = STELLAR_NETWORK_CONFIG[stellarNetwork]; + export const config = { PORT: process.env.PORT || 3000, WHATSAPP_TOKEN: process.env.WHATSAPP_TOKEN || '', @@ -20,7 +44,7 @@ export const config = { WHATSAPP_APP_SECRET: process.env.WHATSAPP_APP_SECRET || '', VERIFY_TOKEN: process.env.VERIFY_TOKEN || 'kolo_verify_token', DATABASE_URL: process.env.DATABASE_URL || '', - STELLAR_NETWORK: process.env.STELLAR_NETWORK || 'TESTNET', // TESTNET or PUBLIC + STELLAR_NETWORK: stellarNetwork, REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379', ENCRYPTION_KEY: process.env.ENCRYPTION_KEY || '', // Must be a 32-byte hex string CURRENT_ENCRYPTION_KEY_VERSION: currentVersionParsed, @@ -30,7 +54,7 @@ export const config = { 3: process.env.ENCRYPTION_KEY_V3 || '', } as Record, HMAC_KEY: process.env.HMAC_KEY || '', // Must be a 32-byte hex string, different from ENCRYPTION_KEY - USDC_ISSUER_PUBLIC_KEY: process.env.USDC_ISSUER_PUBLIC_KEY || '', // SDF testnet issuer or Circle/centre.io mainnet issuer, depending on STELLAR_NETWORK + USDC_ISSUER_PUBLIC_KEY: process.env.USDC_ISSUER_PUBLIC_KEY || '', // SDF Testnet issuer or Circle/centre.io mainnet issuer, depending on STELLAR_NETWORK SOROBAN_RPC_URL: process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org', CONTRACT_WASM_PATH: process.env.CONTRACT_WASM_PATH || 'contracts/savings_group.wasm', DEPLOYER_SECRET_KEY: process.env.DEPLOYER_SECRET_KEY || '', @@ -41,5 +65,6 @@ export const config = { SEP10_HOME_DOMAIN: process.env.SEP10_HOME_DOMAIN || 'localhost', JWT_SECRET: process.env.JWT_SECRET, SEP10_CHALLENGE_TTL_SECONDS: parseInt(process.env.SEP10_CHALLENGE_TTL_SECONDS || '300', 10), + STELLAR_HORIZON_URL: stellarNetworkConfig.horizonUrl, + STELLAR_NETWORK_PASSPHRASE: stellarNetworkConfig.networkPassphrase, }; - diff --git a/src/services/stellar.service.ts b/src/services/stellar.service.ts index 834d0d5..8e1bd01 100644 --- a/src/services/stellar.service.ts +++ b/src/services/stellar.service.ts @@ -31,15 +31,23 @@ export class InsufficientReserveError extends Error { */ export class StellarService { private server: StellarSdk.Horizon.Server; + private networkPassphrase: string; /** * Initializes the Stellar horizon connection based on configured network mode. */ constructor() { - if (config.STELLAR_NETWORK === 'PUBLIC') { + // config.STELLAR_NETWORK is validated at boot (see src/config/env.ts), so + // it is guaranteed to be either 'TESTNET' or 'PUBLIC' here. Derive both + // the Horizon host and the network passphrase from this single value so + // they can never disagree. + const network = config.STELLAR_NETWORK; + if (network === 'PUBLIC') { this.server = new StellarSdk.Horizon.Server('https://horizon.stellar.org'); + this.networkPassphrase = StellarSdk.Networks.PUBLIC; } else { this.server = new StellarSdk.Horizon.Server('https://horizon-testnet.stellar.org'); + this.networkPassphrase = StellarSdk.Networks.TESTNET; } } @@ -374,12 +382,6 @@ export class StellarService { return { hash: result.hash }; } - private networkPassphrase(): string { - return config.STELLAR_NETWORK === 'TESTNET' - ? StellarSdk.Networks.TESTNET - : StellarSdk.Networks.PUBLIC; - } - private isLowReserveError(error: any): boolean { const resultCodes = error?.response?.data?.extras?.result_codes; return resultCodes?.operations?.includes('op_low_reserve') || resultCodes?.transaction === 'tx_insufficient_balance';