diff --git a/.env.example b/.env.example index 0c77fe5..e8904a4 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,24 @@ STELLAR_CLI_PATH=stellar # Default: info LOG_LEVEL=info +# ─── Access Control (optional) ─────────────────────────────────────────────── +# Restrict outbound HTTP requests to specific domains or IP ranges. +# If unset, all destinations are permitted (permissive default). +# Once ANY entry is set, ONLY matching destinations are allowed. +# +# Comma-separated list of allowed hostnames. +# The full list of built-in Stellar endpoints used by Pulsar: +# Horizon (mainnet): horizon.stellar.org +# Horizon (testnet): horizon-testnet.stellar.org +# Horizon (futurenet): horizon-futurenet.stellar.org +# RPC (mainnet): soroban-rpc.stellar.org +# RPC (testnet): soroban-testnet.stellar.org +# RPC (futurenet): rpc-futurenet.stellar.org +# +# ALLOWED_DOMAINS=horizon.stellar.org,horizon-testnet.stellar.org,horizon-futurenet.stellar.org,soroban-rpc.stellar.org,soroban-testnet.stellar.org,rpc-futurenet.stellar.org +# +# Comma-separated list of allowed IPv4 CIDR ranges: +# ALLOWED_IP_RANGES=192.168.1.0/24,10.0.0.0/8 # ─── Encrypted IPC (optional) ─────────────────────────────────────────────── # 32-byte key to encrypt stdio MCP traffic in shared environments. # Provide as 64 hex chars or base64. When set, the MCP client must send/receive diff --git a/src/services/access-control.ts b/src/services/access-control.ts new file mode 100644 index 0000000..6723f0f --- /dev/null +++ b/src/services/access-control.ts @@ -0,0 +1,112 @@ +import { PulsarValidationError } from "../errors.js"; +import logger from "../logger.js"; + +/** + * Parses a CIDR string into a network address (as 32-bit int) and mask. + * Supports only IPv4. + */ +function parseCidr(cidr: string): { network: number; mask: number } | null { + const parts = cidr.trim().split("/"); + if (parts.length !== 2) return null; + + const [ipPart, prefixLenStr] = parts; + const prefixLen = parseInt(prefixLenStr, 10); + + if (isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) return null; + + const octets = ipPart.split(".").map(Number); + if (octets.length !== 4 || octets.some((o) => isNaN(o) || o < 0 || o > 255)) return null; + + const network = + ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0; + const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0; + + return { network: network & mask, mask }; +} + +/** + * Converts an IPv4 string to a 32-bit unsigned integer. + */ +function ipToInt(ip: string): number | null { + const octets = ip.split(".").map(Number); + if (octets.length !== 4 || octets.some((o) => isNaN(o) || o < 0 || o > 255)) return null; + return ( + ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0 + ); +} + +/** + * AccessControl enforces an operator-defined allowlist of domains and CIDR ranges + * for all outbound HTTP requests made by the Pulsar MCP server. + * + * If no allowlist is configured (permissive default), all destinations are allowed. + * Once any allowlist entry is configured, only matching destinations are permitted. + * + * Time Complexity: O(D) for domain lookups, O(R) for CIDR checks + * Space Complexity: O(D + R) where D = domains, R = CIDR ranges + */ +export class AccessControl { + private readonly allowedDomains: Set; + private readonly allowedCidrs: Array<{ network: number; mask: number }>; + private readonly isEnabled: boolean; + + constructor(allowedDomains: string[], allowedCidrs: string[]) { + this.allowedDomains = new Set( + allowedDomains.map((d) => d.trim().toLowerCase()).filter(Boolean) + ); + + this.allowedCidrs = allowedCidrs + .map((c) => parseCidr(c.trim())) + .filter((c): c is { network: number; mask: number } => c !== null); + + this.isEnabled = this.allowedDomains.size > 0 || this.allowedCidrs.length > 0; + } + + /** + * Asserts that the given URL is permitted by the access control policy. + * Throws PulsarValidationError if the URL is blocked. + */ + assertAllowed(url: string): void { + if (!this.isEnabled) return; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new PulsarValidationError(`Invalid URL format: ${url}`); + } + + const hostname = parsed.hostname.toLowerCase(); + + // 1. Domain allowlist check — O(D) + if (this.allowedDomains.has(hostname)) return; + + // 2. CIDR allowlist check — O(R) + const ipInt = ipToInt(hostname); + if (ipInt !== null) { + for (const { network, mask } of this.allowedCidrs) { + if ((ipInt & mask) === network) return; + } + } + + logger.warn({ url, hostname }, "Access control: outbound request blocked"); + throw new PulsarValidationError( + `Outbound request to "${hostname}" is not permitted. ` + + `Add it to ALLOWED_DOMAINS or ALLOWED_IP_RANGES to allow access.` + ); + } +} + +/** + * Singleton instance built from environment variables. + * + * ALLOWED_DOMAINS — comma-separated hostnames, e.g. "horizon.stellar.org,soroban-testnet.stellar.org" + * ALLOWED_IP_RANGES — comma-separated CIDR ranges, e.g. "10.0.0.0/8,192.168.1.0/24" + */ +const rawDomains = process.env.ALLOWED_DOMAINS ?? ""; +const rawCidrs = process.env.ALLOWED_IP_RANGES ?? ""; + +export const accessControl = new AccessControl( + rawDomains ? rawDomains.split(",") : [], + rawCidrs ? rawCidrs.split(",") : [] +); diff --git a/src/services/horizon.ts b/src/services/horizon.ts index 81326f3..78f0c9f 100644 --- a/src/services/horizon.ts +++ b/src/services/horizon.ts @@ -1,5 +1,8 @@ import { Horizon } from '@stellar/stellar-sdk'; +import { config } from "../config.js"; +import { PulsarValidationError } from "../errors.js"; +import { accessControl } from "./access-control.js"; import { config } from '../config.js'; import { PulsarValidationError } from '../errors.js'; @@ -11,6 +14,14 @@ const NETWORK_HORIZON_URLS: Record = { export function getHorizonUrl(network?: string): string { const net = network ?? config.stellarNetwork; + if (net === "custom") { + if (!config.horizonUrl) throw new PulsarValidationError("HORIZON_URL must be set for custom network"); + accessControl.assertAllowed(config.horizonUrl); + return config.horizonUrl; + } + const url = NETWORK_HORIZON_URLS[net] ?? NETWORK_HORIZON_URLS["testnet"]; + accessControl.assertAllowed(url); + return url; if (net === 'custom') { if (!config.horizonUrl) throw new PulsarValidationError('HORIZON_URL must be set for custom network'); diff --git a/src/services/soroban-rpc.ts b/src/services/soroban-rpc.ts index 8b5db9c..160940a 100644 --- a/src/services/soroban-rpc.ts +++ b/src/services/soroban-rpc.ts @@ -1,6 +1,9 @@ import { SorobanRpc } from '@stellar/stellar-sdk'; import { SorobanRpc, Networks, TransactionBuilder, xdr } from '@stellar/stellar-sdk'; +import { config } from "../config.js"; +import { PulsarValidationError } from "../errors.js"; +import { accessControl } from "./access-control.js"; import { config } from '../config.js'; import { PulsarValidationError } from '../errors.js'; @@ -25,6 +28,14 @@ import { getSorobanServer as routerGetSorobanServer, getBestRpcUrl as routerGetB */ export function getRpcUrl(network?: string): string { const net = network ?? config.stellarNetwork; + if (net === "custom") { + if (!config.sorobanRpcUrl) throw new PulsarValidationError("SOROBAN_RPC_URL must be set for custom network"); + accessControl.assertAllowed(config.sorobanRpcUrl); + return config.sorobanRpcUrl; + } + const url = NETWORK_RPC_URLS[net] ?? NETWORK_RPC_URLS["testnet"]; + accessControl.assertAllowed(url); + return url; if (net === 'custom') { if (!config.sorobanRpcUrl) throw new PulsarValidationError('SOROBAN_RPC_URL must be set for custom network');