Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions src/services/access-control.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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(",") : []
);
11 changes: 11 additions & 0 deletions src/services/horizon.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,6 +14,14 @@ const NETWORK_HORIZON_URLS: Record<string, string> = {

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');
Expand Down
11 changes: 11 additions & 0 deletions src/services/soroban-rpc.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
Expand Down
Loading