From 75d021d659a368d84995dffd13eef1714ab4d4df Mon Sep 17 00:00:00 2001 From: ugoocreates-pixel Date: Thu, 1 Oct 2026 09:46:02 -0700 Subject: [PATCH] feat: implement outbound DNS rebinding protection --- src/integrations/github/github-client.ts | 3 + .../webhook-dispatcher.service.ts | 14 ++- src/security/dns.spec.ts | 116 ++++++++++++++++++ src/security/dns.ts | 75 +++++++++++ src/simulation/stellar/stellar-rpc-client.ts | 3 + 5 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 src/security/dns.spec.ts create mode 100644 src/security/dns.ts diff --git a/src/integrations/github/github-client.ts b/src/integrations/github/github-client.ts index ddedcbaf..80c7ebeb 100644 --- a/src/integrations/github/github-client.ts +++ b/src/integrations/github/github-client.ts @@ -7,6 +7,7 @@ import axios, { AxiosInstance, AxiosError } from 'axios'; import { GitHubAuthConfig, GitHubRepository, AccessDiagnosticResult } from './types'; +import { createSafeAgent } from '../../security/dns'; export class GitHubClient { private client: AxiosInstance; @@ -16,6 +17,8 @@ export class GitHubClient { this.auth = auth; this.client = axios.create({ baseURL: 'https://api.github.com', + httpAgent: createSafeAgent('http'), + httpsAgent: createSafeAgent('https'), headers: { 'Accept': 'application/vnd.github.v3+json', ...(auth.token && { 'Authorization': `Bearer ${auth.token}` }), diff --git a/src/modules/gas-spike-alert/webhook-dispatcher.service.ts b/src/modules/gas-spike-alert/webhook-dispatcher.service.ts index d068fb36..372f302f 100644 --- a/src/modules/gas-spike-alert/webhook-dispatcher.service.ts +++ b/src/modules/gas-spike-alert/webhook-dispatcher.service.ts @@ -133,20 +133,26 @@ export class WebhookDispatcherService { const body = JSON.stringify(payload); const signature = this.sign(body, sub.secret); + const { createSafeAgent } = require('../../security/dns'); + const axios = require('axios'); + const httpAgent = createSafeAgent('http'); + const httpsAgent = createSafeAgent('https'); + for (let attempt = 1; attempt <= WEBHOOK_MAX_RETRIES; attempt++) { try { - const response = await fetch(sub.url, { - method: 'POST', + const response = await axios.post(sub.url, payload, { + httpAgent, + httpsAgent, + validateStatus: () => true, headers: { 'Content-Type': 'application/json', 'X-GasGuard-Signature': `${WEBHOOK_HMAC_ALGO}=${signature}`, 'X-GasGuard-Subscription-ID': sub.id, 'X-GasGuard-Timestamp': String(payload.timestamp), }, - body, }); - if (response.ok) { + if (response.status >= 200 && response.status < 300) { this.logger.log( `Webhook ${sub.id} dispatched successfully (attempt ${attempt})`, ); diff --git a/src/security/dns.spec.ts b/src/security/dns.spec.ts new file mode 100644 index 00000000..96f11b07 --- /dev/null +++ b/src/security/dns.spec.ts @@ -0,0 +1,116 @@ +import { isPrivateIP, createSafeAgent } from './dns'; +import * as dns from 'dns'; +import * as http from 'http'; +import * as https from 'https'; + +jest.mock('dns'); + +describe('DNS Security Module', () => { + describe('isPrivateIP', () => { + it('should identify IPv4 loopback as private', () => { + expect(isPrivateIP('127.0.0.1')).toBe(true); + expect(isPrivateIP('127.123.0.1')).toBe(true); + }); + + it('should identify IPv4 zero address as private', () => { + expect(isPrivateIP('0.0.0.0')).toBe(true); + }); + + it('should identify private IPv4 ranges', () => { + expect(isPrivateIP('10.0.0.1')).toBe(true); + expect(isPrivateIP('192.168.1.1')).toBe(true); + expect(isPrivateIP('172.16.0.1')).toBe(true); + expect(isPrivateIP('172.31.255.255')).toBe(true); + }); + + it('should identify carrier-grade NAT as private', () => { + expect(isPrivateIP('100.64.0.1')).toBe(true); + expect(isPrivateIP('100.127.255.255')).toBe(true); + }); + + it('should identify link-local IPv4 as private', () => { + expect(isPrivateIP('169.254.169.254')).toBe(true); + }); + + it('should identify IPv6 loopback as private', () => { + expect(isPrivateIP('::1')).toBe(true); + expect(isPrivateIP('::ffff:127.0.0.1')).toBe(true); + }); + + it('should identify private IPv6 ranges', () => { + expect(isPrivateIP('fc00::1')).toBe(true); + expect(isPrivateIP('fd00::1')).toBe(true); + expect(isPrivateIP('fe80::1')).toBe(true); + }); + + it('should allow public IP addresses', () => { + expect(isPrivateIP('8.8.8.8')).toBe(false); + expect(isPrivateIP('1.1.1.1')).toBe(false); + expect(isPrivateIP('172.15.255.255')).toBe(false); // Just outside private range + expect(isPrivateIP('172.32.0.0')).toBe(false); // Just outside private range + expect(isPrivateIP('2606:4700:4700::1111')).toBe(false); + }); + }); + + describe('createSafeAgent', () => { + let mockLookup: jest.Mock; + + beforeEach(() => { + mockLookup = dns.lookup as unknown as jest.Mock; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should return an http.Agent when protocol is http', () => { + const agent = createSafeAgent('http'); + expect(agent).toBeInstanceOf(http.Agent); + }); + + it('should return an https.Agent when protocol is https', () => { + const agent = createSafeAgent('https'); + expect(agent).toBeInstanceOf(https.Agent); + }); + + it('should allow DNS lookup for public IPs', (done) => { + mockLookup.mockImplementation((hostname, options, callback) => { + callback(null, '8.8.8.8', 4); + }); + + const agent = createSafeAgent('http') as any; + agent.options.lookup('example.com', {}, (err: any, address: string, family: number) => { + expect(err).toBeNull(); + expect(address).toBe('8.8.8.8'); + done(); + }); + }); + + it('should reject DNS lookup for private IPs', (done) => { + mockLookup.mockImplementation((hostname, options, callback) => { + callback(null, '127.0.0.1', 4); + }); + + const agent = createSafeAgent('http') as any; + agent.options.lookup('internal.example.com', {}, (err: any) => { + expect(err).toBeDefined(); + expect(err.code).toBe('EDNSREBIND'); + expect(err.message).toContain('DNS rebinding protection triggered'); + done(); + }); + }); + + it('should pass through DNS lookup errors', (done) => { + const dnsError = new Error('ENOTFOUND'); + mockLookup.mockImplementation((hostname, options, callback) => { + callback(dnsError, null, null); + }); + + const agent = createSafeAgent('http') as any; + agent.options.lookup('nonexistent.example.com', {}, (err: any) => { + expect(err).toBe(dnsError); + done(); + }); + }); + }); +}); diff --git a/src/security/dns.ts b/src/security/dns.ts new file mode 100644 index 00000000..1870800a --- /dev/null +++ b/src/security/dns.ts @@ -0,0 +1,75 @@ +import * as dns from 'dns'; +import * as http from 'http'; +import * as https from 'https'; + +/** + * Checks if an IP address is a private/internal IP. + * Protects against basic SSRF and DNS rebinding to internal network assets. + */ +export function isPrivateIP(ip: string): boolean { + // IPv4 loopback + if (ip.startsWith('127.')) return true; + if (ip === '0.0.0.0') return true; + + // IPv4 private ranges + if (ip.startsWith('10.')) return true; + if (ip.startsWith('192.168.')) return true; + + if (ip.startsWith('172.')) { + const secondOctet = parseInt(ip.split('.')[1], 10); + if (secondOctet >= 16 && secondOctet <= 31) { + return true; + } + } + + // Carrier-grade NAT + if (ip.startsWith('100.')) { + const secondOctet = parseInt(ip.split('.')[1], 10); + if (secondOctet >= 64 && secondOctet <= 127) { + return true; + } + } + + // IPv6 loopback and private + const lowerIp = ip.toLowerCase(); + if (lowerIp === '::1') return true; + if (lowerIp.startsWith('fc00:') || lowerIp.startsWith('fd00:')) return true; + if (lowerIp.startsWith('fe80:')) return true; + if (lowerIp.startsWith('::ffff:127.')) return true; + + // Link-local IPv4 + if (ip.startsWith('169.254.')) return true; + + return false; +} + +/** + * Creates an HTTP/HTTPS agent that protects against DNS rebinding. + * It overrides the dns.lookup function to ensure the IP doesn't change + * to a private/internal IP maliciously, and connects directly to the resolved IP. + */ +export function createSafeAgent(protocol: 'http' | 'https'): http.Agent | https.Agent { + const lookup = ( + hostname: string, + options: dns.LookupOneOptions, + callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void + ) => { + dns.lookup(hostname, options, (err, address, family) => { + if (err) { + return callback(err, address, family); + } + + if (isPrivateIP(address)) { + const error = new Error(`DNS rebinding protection triggered: resolved IP ${address} for hostname ${hostname} is not allowed.`); + (error as any).code = 'EDNSREBIND'; + return callback(error as NodeJS.ErrnoException, address, family); + } + + callback(null, address, family); + }); + }; + + const agentOptions = { lookup }; + + return protocol === 'https' ? new https.Agent(agentOptions) : new http.Agent(agentOptions); +} diff --git a/src/simulation/stellar/stellar-rpc-client.ts b/src/simulation/stellar/stellar-rpc-client.ts index e7a15ac2..ed469da7 100644 --- a/src/simulation/stellar/stellar-rpc-client.ts +++ b/src/simulation/stellar/stellar-rpc-client.ts @@ -7,6 +7,7 @@ import axios, { AxiosInstance, AxiosError } from "axios"; import { SimulationConfig } from "./types"; +import { createSafeAgent } from "../../security/dns"; /** * JSON-RPC 2.0 request structure @@ -54,6 +55,8 @@ export class StellarRpcClient { this.client = axios.create({ baseURL: rpcUrl, timeout: this.config.timeout, + httpAgent: createSafeAgent("http"), + httpsAgent: createSafeAgent("https"), headers: { "Content-Type": "application/json", ...this.config.headers,