From 5ea5d3a869fe9504e6b8809706ea48651bbe4704 Mon Sep 17 00:00:00 2001 From: nelsson007 Date: Wed, 29 Apr 2026 12:56:11 +0100 Subject: [PATCH] feat: implement domain-based access control for outbound requests (#70) --- .env.example | 19 ++++++ src/services/access-control.ts | 112 +++++++++++++++++++++++++++++++++ src/services/horizon.ts | 6 +- src/services/soroban-rpc.ts | 6 +- 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/services/access-control.ts diff --git a/.env.example b/.env.example index bec430f..63a90d2 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,22 @@ STELLAR_CLI_PATH=stellar # Log level: error | warn | info | debug # 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 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 5e571fc..08a4638 100644 --- a/src/services/horizon.ts +++ b/src/services/horizon.ts @@ -2,6 +2,7 @@ import { Horizon } from "@stellar/stellar-sdk"; import { config } from "../config.js"; import { PulsarValidationError } from "../errors.js"; +import { accessControl } from "./access-control.js"; const NETWORK_HORIZON_URLS: Record = { mainnet: "https://horizon.stellar.org", @@ -13,9 +14,12 @@ 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; } - return NETWORK_HORIZON_URLS[net] ?? NETWORK_HORIZON_URLS["testnet"]; + const url = NETWORK_HORIZON_URLS[net] ?? NETWORK_HORIZON_URLS["testnet"]; + accessControl.assertAllowed(url); + return url; } export function getHorizonServer(network?: string): Horizon.Server { diff --git a/src/services/soroban-rpc.ts b/src/services/soroban-rpc.ts index cc96221..8c0b838 100644 --- a/src/services/soroban-rpc.ts +++ b/src/services/soroban-rpc.ts @@ -2,6 +2,7 @@ import { SorobanRpc } from "@stellar/stellar-sdk"; import { config } from "../config.js"; import { PulsarValidationError } from "../errors.js"; +import { accessControl } from "./access-control.js"; const NETWORK_RPC_URLS: Record = { mainnet: "https://soroban-rpc.stellar.org", @@ -13,9 +14,12 @@ 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; } - return NETWORK_RPC_URLS[net] ?? NETWORK_RPC_URLS["testnet"]; + const url = NETWORK_RPC_URLS[net] ?? NETWORK_RPC_URLS["testnet"]; + accessControl.assertAllowed(url); + return url; }