Skip to content

Commit a0e96f6

Browse files
authored
Merge pull request #286 from Hillzo/fix-domain
feat: Implement Domain-Based Access Control (#70)
2 parents 4b789b1 + 729927e commit a0e96f6

4 files changed

Lines changed: 152 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ STELLAR_CLI_PATH=stellar
3232
# Default: info
3333
LOG_LEVEL=info
3434

35+
# ─── Access Control (optional) ───────────────────────────────────────────────
36+
# Restrict outbound HTTP requests to specific domains or IP ranges.
37+
# If unset, all destinations are permitted (permissive default).
38+
# Once ANY entry is set, ONLY matching destinations are allowed.
39+
#
40+
# Comma-separated list of allowed hostnames.
41+
# The full list of built-in Stellar endpoints used by Pulsar:
42+
# Horizon (mainnet): horizon.stellar.org
43+
# Horizon (testnet): horizon-testnet.stellar.org
44+
# Horizon (futurenet): horizon-futurenet.stellar.org
45+
# RPC (mainnet): soroban-rpc.stellar.org
46+
# RPC (testnet): soroban-testnet.stellar.org
47+
# RPC (futurenet): rpc-futurenet.stellar.org
48+
#
49+
# 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
50+
#
51+
# Comma-separated list of allowed IPv4 CIDR ranges:
52+
# ALLOWED_IP_RANGES=192.168.1.0/24,10.0.0.0/8
3553
# ─── Encrypted IPC (optional) ───────────────────────────────────────────────
3654
# 32-byte key to encrypt stdio MCP traffic in shared environments.
3755
# Provide as 64 hex chars or base64. When set, the MCP client must send/receive

‎src/services/access-control.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { PulsarValidationError } from "../errors.js";
2+
import logger from "../logger.js";
3+
4+
/**
5+
* Parses a CIDR string into a network address (as 32-bit int) and mask.
6+
* Supports only IPv4.
7+
*/
8+
function parseCidr(cidr: string): { network: number; mask: number } | null {
9+
const parts = cidr.trim().split("/");
10+
if (parts.length !== 2) return null;
11+
12+
const [ipPart, prefixLenStr] = parts;
13+
const prefixLen = parseInt(prefixLenStr, 10);
14+
15+
if (isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) return null;
16+
17+
const octets = ipPart.split(".").map(Number);
18+
if (octets.length !== 4 || octets.some((o) => isNaN(o) || o < 0 || o > 255)) return null;
19+
20+
const network =
21+
((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
22+
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0;
23+
24+
return { network: network & mask, mask };
25+
}
26+
27+
/**
28+
* Converts an IPv4 string to a 32-bit unsigned integer.
29+
*/
30+
function ipToInt(ip: string): number | null {
31+
const octets = ip.split(".").map(Number);
32+
if (octets.length !== 4 || octets.some((o) => isNaN(o) || o < 0 || o > 255)) return null;
33+
return (
34+
((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0
35+
);
36+
}
37+
38+
/**
39+
* AccessControl enforces an operator-defined allowlist of domains and CIDR ranges
40+
* for all outbound HTTP requests made by the Pulsar MCP server.
41+
*
42+
* If no allowlist is configured (permissive default), all destinations are allowed.
43+
* Once any allowlist entry is configured, only matching destinations are permitted.
44+
*
45+
* Time Complexity: O(D) for domain lookups, O(R) for CIDR checks
46+
* Space Complexity: O(D + R) where D = domains, R = CIDR ranges
47+
*/
48+
export class AccessControl {
49+
private readonly allowedDomains: Set<string>;
50+
private readonly allowedCidrs: Array<{ network: number; mask: number }>;
51+
private readonly isEnabled: boolean;
52+
53+
constructor(allowedDomains: string[], allowedCidrs: string[]) {
54+
this.allowedDomains = new Set(
55+
allowedDomains.map((d) => d.trim().toLowerCase()).filter(Boolean)
56+
);
57+
58+
this.allowedCidrs = allowedCidrs
59+
.map((c) => parseCidr(c.trim()))
60+
.filter((c): c is { network: number; mask: number } => c !== null);
61+
62+
this.isEnabled = this.allowedDomains.size > 0 || this.allowedCidrs.length > 0;
63+
}
64+
65+
/**
66+
* Asserts that the given URL is permitted by the access control policy.
67+
* Throws PulsarValidationError if the URL is blocked.
68+
*/
69+
assertAllowed(url: string): void {
70+
if (!this.isEnabled) return;
71+
72+
let parsed: URL;
73+
try {
74+
parsed = new URL(url);
75+
} catch {
76+
throw new PulsarValidationError(`Invalid URL format: ${url}`);
77+
}
78+
79+
const hostname = parsed.hostname.toLowerCase();
80+
81+
// 1. Domain allowlist check — O(D)
82+
if (this.allowedDomains.has(hostname)) return;
83+
84+
// 2. CIDR allowlist check — O(R)
85+
const ipInt = ipToInt(hostname);
86+
if (ipInt !== null) {
87+
for (const { network, mask } of this.allowedCidrs) {
88+
if ((ipInt & mask) === network) return;
89+
}
90+
}
91+
92+
logger.warn({ url, hostname }, "Access control: outbound request blocked");
93+
throw new PulsarValidationError(
94+
`Outbound request to "${hostname}" is not permitted. ` +
95+
`Add it to ALLOWED_DOMAINS or ALLOWED_IP_RANGES to allow access.`
96+
);
97+
}
98+
}
99+
100+
/**
101+
* Singleton instance built from environment variables.
102+
*
103+
* ALLOWED_DOMAINS — comma-separated hostnames, e.g. "horizon.stellar.org,soroban-testnet.stellar.org"
104+
* ALLOWED_IP_RANGES — comma-separated CIDR ranges, e.g. "10.0.0.0/8,192.168.1.0/24"
105+
*/
106+
const rawDomains = process.env.ALLOWED_DOMAINS ?? "";
107+
const rawCidrs = process.env.ALLOWED_IP_RANGES ?? "";
108+
109+
export const accessControl = new AccessControl(
110+
rawDomains ? rawDomains.split(",") : [],
111+
rawCidrs ? rawCidrs.split(",") : []
112+
);

‎src/services/horizon.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Horizon } from '@stellar/stellar-sdk';
22

3+
import { config } from "../config.js";
4+
import { PulsarValidationError } from "../errors.js";
5+
import { accessControl } from "./access-control.js";
36
import { config } from '../config.js';
47
import { PulsarValidationError } from '../errors.js';
58

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

1215
export function getHorizonUrl(network?: string): string {
1316
const net = network ?? config.stellarNetwork;
17+
if (net === "custom") {
18+
if (!config.horizonUrl) throw new PulsarValidationError("HORIZON_URL must be set for custom network");
19+
accessControl.assertAllowed(config.horizonUrl);
20+
return config.horizonUrl;
21+
}
22+
const url = NETWORK_HORIZON_URLS[net] ?? NETWORK_HORIZON_URLS["testnet"];
23+
accessControl.assertAllowed(url);
24+
return url;
1425
if (net === 'custom') {
1526
if (!config.horizonUrl)
1627
throw new PulsarValidationError('HORIZON_URL must be set for custom network');

‎src/services/soroban-rpc.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { SorobanRpc } from '@stellar/stellar-sdk';
22
import { SorobanRpc, Networks, TransactionBuilder, xdr } from '@stellar/stellar-sdk';
33

4+
import { config } from "../config.js";
5+
import { PulsarValidationError } from "../errors.js";
6+
import { accessControl } from "./access-control.js";
47
import { config } from '../config.js';
58
import { PulsarValidationError } from '../errors.js';
69

@@ -25,6 +28,14 @@ import { getSorobanServer as routerGetSorobanServer, getBestRpcUrl as routerGetB
2528
*/
2629
export function getRpcUrl(network?: string): string {
2730
const net = network ?? config.stellarNetwork;
31+
if (net === "custom") {
32+
if (!config.sorobanRpcUrl) throw new PulsarValidationError("SOROBAN_RPC_URL must be set for custom network");
33+
accessControl.assertAllowed(config.sorobanRpcUrl);
34+
return config.sorobanRpcUrl;
35+
}
36+
const url = NETWORK_RPC_URLS[net] ?? NETWORK_RPC_URLS["testnet"];
37+
accessControl.assertAllowed(url);
38+
return url;
2839
if (net === 'custom') {
2940
if (!config.sorobanRpcUrl)
3041
throw new PulsarValidationError('SOROBAN_RPC_URL must be set for custom network');

0 commit comments

Comments
 (0)