|
| 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 | +); |
0 commit comments