mirror of
https://github.com/dcarrillo/atalaya.git
synced 2026-05-18 06:24:12 +00:00
feat: add ssrf protection (#9)
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { HttpCheckRequest, CheckResult } from '../types.js';
|
import type { HttpCheckRequest, CheckResult } from '../types.js';
|
||||||
|
import { isBlockedURL } from '../utils/ssrf.js';
|
||||||
import { sleep } from './utils.js';
|
import { sleep } from './utils.js';
|
||||||
|
|
||||||
const DEFAULT_HEADERS: Record<string, string> = {
|
const DEFAULT_HEADERS: Record<string, string> = {
|
||||||
@@ -11,6 +12,17 @@ export async function executeHttpCheck(check: HttpCheckRequest): Promise<CheckRe
|
|||||||
let lastError = '';
|
let lastError = '';
|
||||||
const headers = { ...DEFAULT_HEADERS, ...check.headers };
|
const headers = { ...DEFAULT_HEADERS, ...check.headers };
|
||||||
|
|
||||||
|
const blockedReason = isBlockedURL(check.target);
|
||||||
|
if (blockedReason) {
|
||||||
|
return {
|
||||||
|
name: check.name,
|
||||||
|
status: 'down',
|
||||||
|
responseTimeMs: 0,
|
||||||
|
error: blockedReason,
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i <= check.retries; i++) {
|
for (let i = 0; i <= check.retries; i++) {
|
||||||
attempts++;
|
attempts++;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { processResults } from './processor/index.js';
|
|||||||
import { formatWebhookPayload } from './alert/index.js';
|
import { formatWebhookPayload } from './alert/index.js';
|
||||||
import { getMonitorStates, writeCheckResults, updateMonitorStates, recordAlert } from './db.js';
|
import { getMonitorStates, writeCheckResults, updateMonitorStates, recordAlert } from './db.js';
|
||||||
import { interpolateSecrets } from './utils/interpolate.js';
|
import { interpolateSecrets } from './utils/interpolate.js';
|
||||||
|
import { isBlockedURL } from './utils/ssrf.js';
|
||||||
import type { Env } from './types.js';
|
import type { Env } from './types.js';
|
||||||
import type { CheckRequest } from './checker/types.js';
|
import type { CheckRequest } from './checker/types.js';
|
||||||
import type { CheckResult } from './processor/types.js';
|
import type { CheckResult } from './processor/types.js';
|
||||||
@@ -257,6 +258,18 @@ async function sendWebhook(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blockedReason = isBlockedURL(payload.url);
|
||||||
|
if (blockedReason) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
event: 'webhook_ssrf_blocked',
|
||||||
|
alert: alert.alertName,
|
||||||
|
reason: blockedReason,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(payload.url, {
|
const response = await fetch(payload.url, {
|
||||||
method: payload.method,
|
method: payload.method,
|
||||||
headers: payload.headers,
|
headers: payload.headers,
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { isBlockedURL } from './ssrf.js';
|
||||||
|
|
||||||
|
describe('isBlockedURL', () => {
|
||||||
|
describe('valid public URLs', () => {
|
||||||
|
it('allows standard https URLs', () => {
|
||||||
|
expect(isBlockedURL('https://example.com')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows standard http URLs', () => {
|
||||||
|
expect(isBlockedURL('http://example.com')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows URLs with paths', () => {
|
||||||
|
expect(isBlockedURL('https://api.example.com/v1/health')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows URLs with ports', () => {
|
||||||
|
expect(isBlockedURL('https://api.example.com:8080/health')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows subdomains', () => {
|
||||||
|
expect(isBlockedURL('https://monitoring.example.com')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows public IP addresses', () => {
|
||||||
|
expect(isBlockedURL('https://8.8.8.8')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows public IP addresses on common ranges', () => {
|
||||||
|
expect(isBlockedURL('https://93.184.216.34')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localhost blocking', () => {
|
||||||
|
it('blocks localhost hostname', () => {
|
||||||
|
const result = isBlockedURL('http://localhost');
|
||||||
|
expect(result).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks localhost with port', () => {
|
||||||
|
const result = isBlockedURL('http://localhost:8080/health');
|
||||||
|
expect(result).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 127.0.0.1', () => {
|
||||||
|
const result = isBlockedURL('http://127.0.0.1');
|
||||||
|
expect(result).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 127.0.0.1 with path', () => {
|
||||||
|
const result = isBlockedURL('http://127.0.0.1/api/health');
|
||||||
|
expect(result).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 0.0.0.0', () => {
|
||||||
|
const result = isBlockedURL('http://0.0.0.0');
|
||||||
|
expect(result).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks ::1 IPv6 loopback', () => {
|
||||||
|
const result = isBlockedURL('http://[::1]');
|
||||||
|
expect(result).toContain('Blocked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RFC 1918 private IPs', () => {
|
||||||
|
it('blocks 10.x.x.x', () => {
|
||||||
|
expect(isBlockedURL('http://10.0.0.1')).toContain('Blocked');
|
||||||
|
expect(isBlockedURL('http://10.255.255.255')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 172.16-31.x.x', () => {
|
||||||
|
expect(isBlockedURL('http://172.16.0.1')).toContain('Blocked');
|
||||||
|
expect(isBlockedURL('http://172.31.255.255')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 192.168.x.x', () => {
|
||||||
|
expect(isBlockedURL('http://192.168.0.1')).toContain('Blocked');
|
||||||
|
expect(isBlockedURL('http://192.168.255.255')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block 172.32.x.x (public range)', () => {
|
||||||
|
expect(isBlockedURL('http://172.32.0.1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RFC 6598 Carrier-grade NAT', () => {
|
||||||
|
it('blocks 100.64.x.x through 100.127.x.x', () => {
|
||||||
|
expect(isBlockedURL('http://100.64.0.1')).toContain('Blocked');
|
||||||
|
expect(isBlockedURL('http://100.127.255.255')).toContain('Blocked');
|
||||||
|
expect(isBlockedURL('http://100.127.0.1')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block 100.63.x.x (public)', () => {
|
||||||
|
expect(isBlockedURL('http://100.63.0.1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block 100.128.x.x (public)', () => {
|
||||||
|
expect(isBlockedURL('http://100.128.0.1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reserved IPs', () => {
|
||||||
|
it('blocks 0.x.x.x', () => {
|
||||||
|
expect(isBlockedURL('http://0.0.0.1')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 127.x.x.x (not just 127.0.0.1)', () => {
|
||||||
|
expect(isBlockedURL('http://127.1.2.3')).toContain('Blocked');
|
||||||
|
expect(isBlockedURL('http://127.255.255.255')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 169.254.x.x (link-local)', () => {
|
||||||
|
expect(isBlockedURL('http://169.254.1.1')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 255.x.x.x (broadcast)', () => {
|
||||||
|
expect(isBlockedURL('http://255.255.255.255')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.local and .localhost TLDs', () => {
|
||||||
|
it('blocks .local hostnames', () => {
|
||||||
|
expect(isBlockedURL('http://my-service.local')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks .localhost hostnames', () => {
|
||||||
|
expect(isBlockedURL('http://app.localhost')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block .local in path', () => {
|
||||||
|
expect(isBlockedURL('https://example.com/local')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('protocol blocking', () => {
|
||||||
|
it('blocks file:// URLs', () => {
|
||||||
|
const result = isBlockedURL('file:///etc/passwd');
|
||||||
|
expect(result).toContain('Blocked protocol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks ftp:// URLs', () => {
|
||||||
|
const result = isBlockedURL('ftp://ftp.example.com');
|
||||||
|
expect(result).toContain('Blocked protocol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks data: URLs', () => {
|
||||||
|
const result = isBlockedURL('data:text/plain,hello');
|
||||||
|
expect(result).toContain('Blocked protocol');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('rejects empty string', () => {
|
||||||
|
expect(isBlockedURL('')).toContain('Invalid URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects garbage input', () => {
|
||||||
|
expect(isBlockedURL('not a url')).toContain('Invalid URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks private IP via HTTPS', () => {
|
||||||
|
expect(isBlockedURL('https://192.168.1.1')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block hostnames that might resolve to private IPs', () => {
|
||||||
|
// We only block by hostname if it's an explicit IP or localhost keyword
|
||||||
|
expect(isBlockedURL('http://internal.service')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks 10.0.0.0 exact', () => {
|
||||||
|
expect(isBlockedURL('http://10.0.0.0')).toContain('Blocked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
const PRIVATE_IP_PREFIXES = [
|
||||||
|
'10.',
|
||||||
|
'172.16.',
|
||||||
|
'172.17.',
|
||||||
|
'172.18.',
|
||||||
|
'172.19.',
|
||||||
|
'172.20.',
|
||||||
|
'172.21.',
|
||||||
|
'172.22.',
|
||||||
|
'172.23.',
|
||||||
|
'172.24.',
|
||||||
|
'172.25.',
|
||||||
|
'172.26.',
|
||||||
|
'172.27.',
|
||||||
|
'172.28.',
|
||||||
|
'172.29.',
|
||||||
|
'172.30.',
|
||||||
|
'172.31.',
|
||||||
|
'192.168.',
|
||||||
|
'100.64.',
|
||||||
|
'100.65.',
|
||||||
|
'100.66.',
|
||||||
|
'100.67.',
|
||||||
|
'100.68.',
|
||||||
|
'100.69.',
|
||||||
|
'100.70.',
|
||||||
|
'100.71.',
|
||||||
|
'100.72.',
|
||||||
|
'100.73.',
|
||||||
|
'100.74.',
|
||||||
|
'100.75.',
|
||||||
|
'100.76.',
|
||||||
|
'100.77.',
|
||||||
|
'100.78.',
|
||||||
|
'100.79.',
|
||||||
|
'100.80.',
|
||||||
|
'100.81.',
|
||||||
|
'100.82.',
|
||||||
|
'100.83.',
|
||||||
|
'100.84.',
|
||||||
|
'100.85.',
|
||||||
|
'100.86.',
|
||||||
|
'100.87.',
|
||||||
|
'100.88.',
|
||||||
|
'100.89.',
|
||||||
|
'100.90.',
|
||||||
|
'100.91.',
|
||||||
|
'100.92.',
|
||||||
|
'100.93.',
|
||||||
|
'100.94.',
|
||||||
|
'100.95.',
|
||||||
|
'100.96.',
|
||||||
|
'100.97.',
|
||||||
|
'100.98.',
|
||||||
|
'100.99.',
|
||||||
|
'100.100.',
|
||||||
|
'100.101.',
|
||||||
|
'100.102.',
|
||||||
|
'100.103.',
|
||||||
|
'100.104.',
|
||||||
|
'100.105.',
|
||||||
|
'100.106.',
|
||||||
|
'100.107.',
|
||||||
|
'100.108.',
|
||||||
|
'100.109.',
|
||||||
|
'100.110.',
|
||||||
|
'100.111.',
|
||||||
|
'100.112.',
|
||||||
|
'100.113.',
|
||||||
|
'100.114.',
|
||||||
|
'100.115.',
|
||||||
|
'100.116.',
|
||||||
|
'100.117.',
|
||||||
|
'100.118.',
|
||||||
|
'100.119.',
|
||||||
|
'100.120.',
|
||||||
|
'100.121.',
|
||||||
|
'100.122.',
|
||||||
|
'100.123.',
|
||||||
|
'100.124.',
|
||||||
|
'100.125.',
|
||||||
|
'100.126.',
|
||||||
|
'100.127.',
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESERVED_IP_PREFIXES = [
|
||||||
|
'0.',
|
||||||
|
'127.',
|
||||||
|
'169.254.',
|
||||||
|
'198.18.',
|
||||||
|
'198.51.100.',
|
||||||
|
'203.0.113.',
|
||||||
|
'192.0.2.',
|
||||||
|
'255.',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOCALHOST_NAMES = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
||||||
|
|
||||||
|
function ipMatchesAnyPrefix(ip: string, prefixes: string[]): boolean {
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
if (ip.startsWith(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlockedURL(urlString: string): string | null {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(urlString);
|
||||||
|
} catch {
|
||||||
|
return 'Invalid URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = url.protocol.toLowerCase();
|
||||||
|
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||||
|
return `Blocked protocol: ${protocol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
|
||||||
|
const strippedHostname = hostname.replaceAll(/^\[|\]$/gv, '');
|
||||||
|
|
||||||
|
if (LOCALHOST_NAMES.has(hostname) || LOCALHOST_NAMES.has(strippedHostname)) {
|
||||||
|
return `Blocked: localhost / loopback address`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strippedHostname.endsWith('.local') || strippedHostname.endsWith('.localhost')) {
|
||||||
|
return `Blocked: localhost / loopback address`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strippedHostname === '::1' || strippedHostname === '0:0:0:0:0:0:0:1') {
|
||||||
|
return 'Blocked: IPv6 loopback address';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for bare IPv4 addresses against private/reserved ranges
|
||||||
|
if (hostname.includes('.')) {
|
||||||
|
if (ipMatchesAnyPrefix(hostname, PRIVATE_IP_PREFIXES)) {
|
||||||
|
return `Blocked: private IP address (${hostname})`;
|
||||||
|
}
|
||||||
|
if (ipMatchesAnyPrefix(hostname, RESERVED_IP_PREFIXES)) {
|
||||||
|
return `Blocked: reserved IP address (${hostname})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user