From 22b75c773f4464685980c61b572f596c6b103dd1 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Sun, 24 May 2026 19:12:40 -0700 Subject: [PATCH] fix: restrict insecure Stellar RPC URLs --- .../src/hooks/use-distribution-transaction.ts | 4 +-- apps/web/src/services/contract.deployer.ts | 19 ++++++++++- apps/web/src/services/stellar.service.ts | 23 +++++++++++-- packages/sdk/DEVELOPMENT_GUIDE.md | 20 +++++++++++- .../src/__tests__/ContractDeployer.test.ts | 32 +++++++++++++++++++ packages/sdk/src/deployer/ContractDeployer.ts | 17 +++++++++- packages/sdk/src/deployer/types.ts | 7 ++++ packages/sdk/src/utils/GasEstimator.ts | 31 +++++++++++------- packages/sdk/src/utils/httpSecurity.ts | 23 +++++++++++++ 9 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 packages/sdk/src/utils/httpSecurity.ts diff --git a/apps/web/src/hooks/use-distribution-transaction.ts b/apps/web/src/hooks/use-distribution-transaction.ts index 9fe31df..27b8c3e 100644 --- a/apps/web/src/hooks/use-distribution-transaction.ts +++ b/apps/web/src/hooks/use-distribution-transaction.ts @@ -34,7 +34,7 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise */ async function accountExists(address: string): Promise { try { - const horizon = new Horizon.Server(HORIZON_URL, { allowHttp: true }); + const horizon = new Horizon.Server(HORIZON_URL); await withTimeout(horizon.loadAccount(address), ACCOUNT_CHECK_TIMEOUT_MS, address); return true; } catch (err) { @@ -55,7 +55,7 @@ async function checkSenderBalance( requiredAmount: bigint ): Promise<{ ok: boolean; reason?: string }> { try { - const horizon = new Horizon.Server(HORIZON_URL, { allowHttp: true }); + const horizon = new Horizon.Server(HORIZON_URL); const account = await horizon.loadAccount(senderAddress); if (tokenAddress === 'native') { diff --git a/apps/web/src/services/contract.deployer.ts b/apps/web/src/services/contract.deployer.ts index 795ed08..538c30d 100644 --- a/apps/web/src/services/contract.deployer.ts +++ b/apps/web/src/services/contract.deployer.ts @@ -14,12 +14,29 @@ interface DeployConfig { networkPassphrase: string; } +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +function allowLocalHttp(url: string): boolean { + if (process.env.NODE_ENV === 'production') { + return false; + } + + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname); + } catch { + return false; + } +} + export class ContractDeployer { private rpcServer: RpcServer; private networkPassphrase: string; constructor(config: DeployConfig) { - this.rpcServer = new RpcServer(config.rpcUrl, { allowHttp: true }); + this.rpcServer = new RpcServer(config.rpcUrl, { + allowHttp: allowLocalHttp(config.rpcUrl), + }); this.networkPassphrase = config.networkPassphrase; } diff --git a/apps/web/src/services/stellar.service.ts b/apps/web/src/services/stellar.service.ts index e79e717..c4dd5e3 100644 --- a/apps/web/src/services/stellar.service.ts +++ b/apps/web/src/services/stellar.service.ts @@ -62,6 +62,21 @@ const DEFAULT_TIMEOUT = 30; // seconds const DEFAULT_MAX_RETRIES = 3; const DEFAULT_BASE_FEE = '100'; // stroops +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +function allowLocalHttp(url: string): boolean { + if (process.env.NODE_ENV === 'production') { + return false; + } + + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname); + } catch { + return false; + } +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -80,8 +95,12 @@ export class StellarService { private readonly maxRetries: number; constructor(config: StellarServiceConfig) { - this.rpcServer = new RpcServer(config.network.rpcUrl, { allowHttp: true }); - this.horizonServer = new Horizon.Server(config.network.horizonUrl, { allowHttp: true }); + this.rpcServer = new RpcServer(config.network.rpcUrl, { + allowHttp: allowLocalHttp(config.network.rpcUrl), + }); + this.horizonServer = new Horizon.Server(config.network.horizonUrl, { + allowHttp: allowLocalHttp(config.network.horizonUrl), + }); this.networkPassphrase = config.network.networkPassphrase; this.paymentStreamContractId = config.contracts.paymentStream; this.distributorContractId = config.contracts.distributor; diff --git a/packages/sdk/DEVELOPMENT_GUIDE.md b/packages/sdk/DEVELOPMENT_GUIDE.md index 5dc6dae..09e40af 100644 --- a/packages/sdk/DEVELOPMENT_GUIDE.md +++ b/packages/sdk/DEVELOPMENT_GUIDE.md @@ -42,6 +42,24 @@ pnpm build This runs `tsc` to compile TypeScript to the `dist/` directory. +### Local RPC over HTTP + +Production and public testnet/mainnet RPC URLs must use HTTPS. Plain HTTP is +only accepted for local loopback development endpoints, and the SDK requires an +explicit opt-in: + +```typescript +const deployer = new ContractDeployer({ + rpcUrl: 'http://localhost:8000', + networkPassphrase: 'Standalone Network ; February 2017', + allowHttp: true, +}); +``` + +Non-loopback HTTP URLs are rejected even when `allowHttp` is set. This keeps +signed transactions and authentication payloads from being sent over cleartext +connections outside local development. + ### 3. Watch Mode (Development) For incremental builds during development: @@ -222,4 +240,4 @@ pnpm test - [README.md](packages/sdk/README.md) — SDK usage and API reference - [STYLE_GUIDE.md](../../STYLE_GUIDE.md) — General coding standards -- [TESTING_GUIDE.md](../../TESTING_GUIDE.md) — Testing best practices \ No newline at end of file +- [TESTING_GUIDE.md](../../TESTING_GUIDE.md) — Testing best practices diff --git a/packages/sdk/src/__tests__/ContractDeployer.test.ts b/packages/sdk/src/__tests__/ContractDeployer.test.ts index 5cd8f1f..4bce203 100644 --- a/packages/sdk/src/__tests__/ContractDeployer.test.ts +++ b/packages/sdk/src/__tests__/ContractDeployer.test.ts @@ -162,6 +162,38 @@ describe('ContractDeployer', () => { }); }); + describe('RPC URL safety', () => { + it('rejects non-loopback HTTP RPC URLs by default', () => { + expect( + () => + new ContractDeployer({ + rpcUrl: 'http://example.com', + networkPassphrase: 'Test SDF Network ; September 2015', + }) + ).toThrow(DeployerError); + }); + + it('requires explicit opt-in for local HTTP RPC URLs', () => { + expect( + () => + new ContractDeployer({ + rpcUrl: 'http://localhost:8000', + networkPassphrase: 'Test SDF Network ; September 2015', + }) + ).toThrow(DeployerError); + }); + + it('allows explicitly opted-in loopback HTTP RPC URLs for local development', () => { + const localDeployer = new ContractDeployer({ + rpcUrl: 'http://127.0.0.1:8000', + networkPassphrase: 'Standalone Network ; February 2017', + allowHttp: true, + }); + + expect(localDeployer).toBeInstanceOf(ContractDeployer); + }); + }); + // ── WASM validation ──────────────────────────────────────────────────────── describe('WASM validation', () => { it('rejects empty WASM buffer', async () => { diff --git a/packages/sdk/src/deployer/ContractDeployer.ts b/packages/sdk/src/deployer/ContractDeployer.ts index 37ff7ca..53bef69 100644 --- a/packages/sdk/src/deployer/ContractDeployer.ts +++ b/packages/sdk/src/deployer/ContractDeployer.ts @@ -28,6 +28,7 @@ import { FeeEstimationError, DeploymentTimeoutError, } from './errors'; +import { shouldAllowLocalHttp } from '../utils/httpSecurity.js'; const DEFAULT_BASE_FEE = '100'; const DEFAULT_TIMEOUT = 60; @@ -70,12 +71,26 @@ export class ContractDeployer { private passphrasePromise: Promise | undefined; constructor(config: DeployerConfig) { - this.rpc = new Server(config.rpcUrl, { allowHttp: true }); + this.rpc = new Server(config.rpcUrl, { + allowHttp: this.getAllowHttp(config.rpcUrl, config.allowHttp), + }); this.networkPassphrase = config.networkPassphrase; this.baseFee = config.baseFee ?? DEFAULT_BASE_FEE; this.timeoutSeconds = config.timeoutSeconds ?? DEFAULT_TIMEOUT; } + private getAllowHttp(rpcUrl: string, allowHttp?: boolean): boolean { + try { + return shouldAllowLocalHttp(rpcUrl, allowHttp); + } catch (error) { + throw new DeployerError( + (error as Error).message, + 'UNSAFE_HTTP_RPC_URL', + error as Error + ); + } + } + // ─── Async factory ───────────────────────────────────────────────────────── /** diff --git a/packages/sdk/src/deployer/types.ts b/packages/sdk/src/deployer/types.ts index c754452..235dffd 100644 --- a/packages/sdk/src/deployer/types.ts +++ b/packages/sdk/src/deployer/types.ts @@ -95,6 +95,13 @@ export interface DeployerConfig { * - Local: `http://localhost:8000` */ rpcUrl: string; + /** + * Allow plain-HTTP RPC URLs for local development only. + * + * This is intentionally off by default and only works with loopback hosts + * such as `localhost`, `127.0.0.1`, or `[::1]`. + */ + allowHttp?: boolean; /** * Network passphrase for transaction signing. * diff --git a/packages/sdk/src/utils/GasEstimator.ts b/packages/sdk/src/utils/GasEstimator.ts index f6564ba..5755d52 100644 --- a/packages/sdk/src/utils/GasEstimator.ts +++ b/packages/sdk/src/utils/GasEstimator.ts @@ -1,5 +1,6 @@ import { xdr } from "@stellar/stellar-sdk"; import { Server, Api } from "@stellar/stellar-sdk/rpc"; +import { shouldAllowLocalHttp } from "./httpSecurity.js"; const DEFAULT_BASE_FEE = "100"; const DEFAULT_RESOURCE_BUFFER = 1.2; @@ -20,6 +21,8 @@ export interface GasEstimatorOptions { rpc?: GasEstimatorRpc; /** Soroban RPC endpoint URL. */ rpcUrl?: string; + /** Allow plain-HTTP RPC URLs for local loopback development. */ + allowHttp?: boolean; /** Base Stellar inclusion fee in stroops. Defaults to 100. */ baseFee?: string; /** Multiplier applied to simulated Soroban resource limits. Defaults to 1.2. */ @@ -73,17 +76,6 @@ export interface GasEstimate { feeStats?: unknown; } -/** - * Estimates Soroban transaction fees and resource limits from simulation data - * plus current RPC fee statistics when available. - */ -export class GasEstimator { - private readonly rpc: GasEstimatorRpc; - private readonly baseFee: string; - private readonly resourceBuffer: number; - private readonly congestionBuffer: number; - private readonly highCongestionBuffer: number; - function assertStroopString(value: string, name: string): string { if (!/^\d+$/.test(value)) { throw new Error(`${name} must be a non-negative integer string in stroops`); @@ -98,12 +90,27 @@ function assertFinitePositiveNumber(value: number, name: string): number { return value; } +/** + * Estimates Soroban transaction fees and resource limits from simulation data + * plus current RPC fee statistics when available. + */ +export class GasEstimator { + private readonly rpc: GasEstimatorRpc; + private readonly baseFee: string; + private readonly resourceBuffer: number; + private readonly congestionBuffer: number; + private readonly highCongestionBuffer: number; + constructor(options: GasEstimatorOptions) { if (!options.rpc && !options.rpcUrl) { throw new Error("GasEstimator requires either rpc or rpcUrl"); } - this.rpc = options.rpc ?? new Server(options.rpcUrl!, { allowHttp: true }); + this.rpc = + options.rpc ?? + new Server(options.rpcUrl!, { + allowHttp: shouldAllowLocalHttp(options.rpcUrl!, options.allowHttp), + }); this.baseFee = assertStroopString( options.baseFee ?? DEFAULT_BASE_FEE, "baseFee" diff --git a/packages/sdk/src/utils/httpSecurity.ts b/packages/sdk/src/utils/httpSecurity.ts new file mode 100644 index 0000000..4ae82af --- /dev/null +++ b/packages/sdk/src/utils/httpSecurity.ts @@ -0,0 +1,23 @@ +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +export function shouldAllowLocalHttp(url: string, allowHttp = false): boolean { + let parsed: URL; + + try { + parsed = new URL(url); + } catch { + return false; + } + + if (parsed.protocol !== 'http:') { + return false; + } + + if (!allowHttp || !LOOPBACK_HOSTS.has(parsed.hostname)) { + throw new Error( + 'HTTP Stellar RPC URLs are only allowed for loopback development endpoints when allowHttp is set to true' + ); + } + + return true; +}