{"version":3,"file":"endpoint-allowlist.mjs","names":[],"sources":["../../../src/services/endpoint-allowlist.ts"],"sourcesContent":["/**\n * Endpoint Allowlist — restrict outbound HTTP requests to approved hosts.\n *\n * Inspired by IronClaw's endpoint allowlisting pattern.\n * Prevents prompt injection attacks from tricking the agent into\n * exfiltrating data to attacker-controlled URLs.\n *\n * Usage:\n *   import { guardedFetch, isAllowedEndpoint } from './endpoint-allowlist.js';\n *\n *   // Use guardedFetch as a drop-in replacement for fetch()\n *   const resp = await guardedFetch('https://api.0x.org/swap/v1/quote?...');\n *\n *   // Or check manually before calling fetch\n *   if (!isAllowedEndpoint(url)) throw new Error('Blocked');\n */\n\n// ─── Allowed Hosts ──────────────────────────────────────────────────────\n// Organized by category. Every external HTTP call our tools make should\n// hit one of these hosts. If a new integration is added, its hosts must\n// be added here.\n\nconst ALLOWED_HOSTS: ReadonlySet<string> = new Set([\n  // ── DEX Aggregators ──────────────────────────────────────────────────\n  'api.0x.org',\n  'api.1inch.dev',\n  'apiv5.paraswap.io',\n  'api.odos.xyz',\n  'aggregator-api.kyberswap.com',\n  'open-api.openocean.finance',\n\n  // ── Price Feeds ──────────────────────────────────────────────────────\n  'api.dexscreener.com',\n  'api.coingecko.com',\n  'pro-api.coingecko.com',\n  'pro-api.coinmarketcap.com',\n  'coins.llama.fi',\n  'yields.llama.fi',\n  'public-api.birdeye.so',\n\n  // ── RPC Providers (accessed via viem, not raw fetch, but listed for completeness)\n  'base-mainnet.g.alchemy.com',\n  'eth-mainnet.g.alchemy.com',\n  'arb-mainnet.g.alchemy.com',\n  'opt-mainnet.g.alchemy.com',\n  'polygon-mainnet.g.alchemy.com',\n  'base.llamarpc.com',\n  'eth.llamarpc.com',\n  'arbitrum.llamarpc.com',\n  'optimism.llamarpc.com',\n  'polygon.llamarpc.com',\n  'mainnet.base.org',\n  'base.drpc.org',\n  'base.meowrpc.com',\n  '1rpc.io',\n  'ethereum.publicnode.com',\n  'eth.drpc.org',\n  'rpc.ankr.com',\n  'arb1.arbitrum.io',\n  'arbitrum.drpc.org',\n  'mainnet.optimism.io',\n  'optimism.drpc.org',\n  'polygon-rpc.com',\n  'polygon.drpc.org',\n\n  // ── Block Explorers ──────────────────────────────────────────────────\n  'api.basescan.org',\n  'api.etherscan.io',\n  'api.arbiscan.io',\n  'api-optimistic.etherscan.io',\n  'api.polygonscan.com',\n\n  // ── Bridge ───────────────────────────────────────────────────────────\n  'li.quest',\n\n  // ── Bankr Agent API ──────────────────────────────────────────────────\n  'api.bankr.bot',\n  'llm.bankr.bot',\n\n  // ── Clawnch Platform ─────────────────────────────────────────────────\n  'clawn.ch',\n  'api.clawn.ch',\n\n  // ── WalletConnect ────────────────────────────────────────────────────\n  'relay.walletconnect.com',\n  'relay.walletconnect.org',\n  'explorer-api.walletconnect.com',\n\n  // ── External Integrations ────────────────────────────────────────────\n  'herdintelligence.com',\n  'api.herdintelligence.com',\n  'api.molten.gg',\n  'api.wayfinder.ai',\n\n  // ── NFT (Reservoir) ───────────────────────────────────────────────\n  'api.reservoir.tools',\n  'api-base.reservoir.tools',\n  'api-arbitrum.reservoir.tools',\n  'api-optimism.reservoir.tools',\n  'api-polygon.reservoir.tools',\n\n  // ── Governance ────────────────────────────────────────────────────────\n  'hub.snapshot.org',\n  'api.tally.xyz',\n\n  // ── Farcaster (Neynar API) ──────────────────────────────────────────\n  'api.neynar.com',\n\n  // ── Safe Transaction Service ──────────────────────────────────────────\n  'safe-transaction-mainnet.safe.global',\n  'safe-transaction-base.safe.global',\n  'safe-transaction-arbitrum.safe.global',\n  'safe-transaction-optimism.safe.global',\n  'safe-transaction-polygon.safe.global',\n\n  // ── Airdrop Eligibility APIs ──────────────────────────────────────────\n  'claims.eigenfoundation.org',\n  'www.layerzero.foundation',\n\n  // ── X/Twitter (ClawnX tool) ──────────────────────────────────────────\n  'api.twitter.com',\n  'api.x.com',\n\n  // ── Telegram Bot API ─────────────────────────────────────────────────\n  'api.telegram.org',\n\n  // ── Fly.io deployment API ────────────────────────────────────────────\n  'api.machines.dev',\n  'api.fly.io',\n]);\n\n// Hosts that are allowed ONLY as exact matches (no subdomain matching).\n// Prevents e.g. \"evil.localhost\" from being allowed.\nconst EXACT_ONLY_HOSTS: ReadonlySet<string> = new Set([\n  'localhost',\n  '127.0.0.1',\n]);\n\n// ── Additional user-configured hosts (loaded from env at startup) ──────\nconst _userHosts = new Set<string>();\n\n// ── Allowlist mode (locked at startup to prevent env injection bypass) ──\nlet _allowlistMode: string = process.env.OPENCLAWNCH_ALLOWLIST_MODE ?? 'enforce';\n\nfunction loadUserHosts(): void {\n  const extra = process.env.OPENCLAWNCH_ALLOWED_HOSTS;\n  if (extra) {\n    for (const h of extra.split(',')) {\n      const trimmed = h.trim().toLowerCase();\n      if (trimmed) _userHosts.add(trimmed);\n    }\n  }\n}\n\n// Load once at module init\nloadUserHosts();\n\n// ─── Core Functions ──────────────────────────────────────────────────────\n\n/**\n * Check if a URL targets an allowed host.\n * Returns true if the host is in the allowlist.\n */\nexport function isAllowedEndpoint(urlOrHost: string): boolean {\n  try {\n    let host: string;\n\n    // Handle bare hostnames (no protocol)\n    if (urlOrHost.includes('://')) {\n      const parsed = new URL(urlOrHost);\n      host = parsed.hostname.toLowerCase();\n    } else {\n      // Could be \"host:port\" or just \"host\"\n      host = urlOrHost.split(':')[0]!.toLowerCase();\n    }\n\n    // Exact-only hosts (no subdomain matching)\n    if (EXACT_ONLY_HOSTS.has(host)) return true;\n\n    // Exact match against main allowlist\n    if (ALLOWED_HOSTS.has(host) || _userHosts.has(host)) return true;\n\n    // Subdomain match: if \"api.example.com\" is allowed, \"v2.api.example.com\" is too\n    // Only applies to ALLOWED_HOSTS and _userHosts, NOT EXACT_ONLY_HOSTS\n    for (const allowed of ALLOWED_HOSTS) {\n      if (host.endsWith(`.${allowed}`)) return true;\n    }\n    for (const allowed of _userHosts) {\n      if (host.endsWith(`.${allowed}`)) return true;\n    }\n\n    return false;\n  } catch {\n    return false; // Malformed URL → deny\n  }\n}\n\n/**\n * A guarded fetch wrapper that blocks requests to non-allowlisted hosts.\n * Drop-in replacement for global `fetch()`.\n *\n * Security features:\n * - Blocks requests to non-allowlisted hosts\n * - Prevents redirect-based allowlist bypass (redirect: 'manual')\n * - Mode locked at startup (cannot be changed via env injection)\n */\nexport async function guardedFetch(\n  input: string | URL | Request,\n  init?: RequestInit,\n): Promise<Response> {\n  if (_allowlistMode === 'off') {\n    return fetch(input, init);\n  }\n\n  const url = typeof input === 'string'\n    ? input\n    : input instanceof URL\n      ? input.toString()\n      : input.url;\n\n  if (!isAllowedEndpoint(url)) {\n    const msg = `[endpoint-allowlist] Blocked request to non-allowlisted host: ${url}`;\n\n    if (_allowlistMode === 'warn') {\n      console.warn(msg);\n      return fetch(input, init);\n    }\n\n    throw new EndpointBlockedError(url);\n  }\n\n  // Force manual redirects to prevent redirect-based allowlist bypass:\n  // An attacker who controls a redirect on an allowlisted host could redirect\n  // to a non-allowlisted URL, exfiltrating data.\n  const mergedInit: RequestInit = { ...init, redirect: 'manual' };\n  const response = await fetch(input, mergedInit);\n\n  // Handle redirects: check the target before following\n  if (response.status >= 300 && response.status < 400) {\n    const location = response.headers.get('location');\n    if (location) {\n      // Resolve relative URLs against the original\n      const resolvedUrl = new URL(location, url).toString();\n\n      if (!isAllowedEndpoint(resolvedUrl)) {\n        const msg = `[endpoint-allowlist] Blocked redirect to non-allowlisted host: ${resolvedUrl} (from ${url})`;\n        if (_allowlistMode === 'warn') {\n          console.warn(msg);\n          return fetch(resolvedUrl, init);\n        }\n        throw new EndpointBlockedError(resolvedUrl);\n      }\n\n      // Redirect target is allowed — follow it\n      return guardedFetch(resolvedUrl, init);\n    }\n  }\n\n  return response;\n}\n\n/**\n * Add a host to the runtime allowlist (does not persist across restarts).\n * Useful for dynamically discovered endpoints (e.g., user-configured RPC URLs).\n */\nexport function addAllowedHost(host: string): void {\n  _userHosts.add(host.toLowerCase());\n}\n\n/**\n * Get the full list of allowed hosts (for diagnostics).\n */\nexport function getAllowedHosts(): string[] {\n  return [...ALLOWED_HOSTS, ...EXACT_ONLY_HOSTS, ..._userHosts].sort();\n}\n\n/**\n * Get the current allowlist mode.\n */\nexport function getAllowlistMode(): string {\n  return _allowlistMode;\n}\n\n/**\n * Re-read the allowlist mode from the current env.\n * ONLY for use in tests — production code should never call this\n * (the mode is locked at startup to prevent runtime env injection).\n */\nexport function _resetAllowlistMode(): void {\n  _allowlistMode = process.env.OPENCLAWNCH_ALLOWLIST_MODE ?? 'enforce';\n}\n\n// ─── Error Class ─────────────────────────────────────────────────────────\n\nexport class EndpointBlockedError extends Error {\n  public readonly blockedUrl: string;\n\n  constructor(url: string) {\n    super(\n      `Request blocked: \"${url}\" is not in the endpoint allowlist. ` +\n      `If this is a legitimate endpoint, add it to OPENCLAWNCH_ALLOWED_HOSTS or the allowlist in endpoint-allowlist.ts.`\n    );\n    this.name = 'EndpointBlockedError';\n    this.blockedUrl = url;\n  }\n}\n"],"mappings":"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"}