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
3 changes: 3 additions & 0 deletions src/integrations/github/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}` }),
Expand Down
14 changes: 10 additions & 4 deletions src/modules/gas-spike-alert/webhook-dispatcher.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`,
);
Expand Down
116 changes: 116 additions & 0 deletions src/security/dns.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
});
75 changes: 75 additions & 0 deletions src/security/dns.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions src/simulation/stellar/stellar-rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading