Skip to content
Open
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
54 changes: 54 additions & 0 deletions api/_common/target-scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import net from 'net';

const IPV4_RANGES = [
['10.0.0.0', '10.255.255.255'],
['127.0.0.0', '127.255.255.255'],
['169.254.0.0', '169.254.255.255'],
['172.16.0.0', '172.31.255.255'],
['192.168.0.0', '192.168.255.255'],
['0.0.0.0', '0.255.255.255'],
];

const ipv4ToInt = (ip) =>
ip
.split('.')
.map((part) => parseInt(part, 10))
.reduce((acc, part) => (acc << 8) + part, 0) >>> 0;

const isPrivateIpv4 = (ip) => {
const value = ipv4ToInt(ip);
return IPV4_RANGES.some(([start, end]) => {
const lower = ipv4ToInt(start);
const upper = ipv4ToInt(end);
return value >= lower && value <= upper;
});
};

const isPrivateIpv6 = (ip) => {
const normalized = ip.toLowerCase();
return (
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80:')
);
};

export const isNonRoutableHost = (hostname) => {
if (!hostname) return false;
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;

const ipVersion = net.isIP(hostname);
if (ipVersion === 4) return isPrivateIpv4(hostname);
if (ipVersion === 6) return isPrivateIpv6(hostname);
return false;
};

export const skipIfNonRoutable = (hostname, serviceName) => {
if (!isNonRoutableHost(hostname)) return null;
return {
skipped: `${serviceName} only runs against publicly routable hosts`,
reason: 'non-routable-host',
hostname,
};
};
37 changes: 37 additions & 0 deletions api/_common/target-scope.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import { isNonRoutableHost, skipIfNonRoutable } from './target-scope.js';

test('isNonRoutableHost flags RFC1918, loopback, link-local, localhost and ULA addresses', () => {
for (const host of [
'10.0.0.1',
'172.16.4.5',
'192.168.1.10',
'127.0.0.1',
'169.254.10.20',
'localhost',
'foo.localhost',
'fc00::1',
'fd12:3456::1',
'fe80::1',
'::1',
]) {
assert.equal(isNonRoutableHost(host), true, host);
}
});

test('isNonRoutableHost keeps public hosts routable', () => {
for (const host of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111', 'example.com']) {
assert.equal(isNonRoutableHost(host), false, host);
}
});

test('skipIfNonRoutable returns a skipped payload with context', () => {
assert.deepEqual(skipIfNonRoutable('192.168.1.20', 'Archive lookup'), {
skipped: 'Archive lookup only runs against publicly routable hosts',
reason: 'non-routable-host',
hostname: '192.168.1.20',
});
assert.equal(skipIfNonRoutable('example.com', 'Archive lookup'), null);
});
6 changes: 6 additions & 0 deletions api/archives.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import middleware from './_common/middleware.js';
import { httpGet } from './_common/http.js';
import { parseTarget } from './_common/parse-target.js';
import { skipIfNonRoutable } from './_common/target-scope.js';

const convertTimestampToDate = (timestamp) => {
const [year, month, day, hour, minute, second] = [
Expand Down Expand Up @@ -47,6 +49,10 @@ const getScanFrequency = (firstScan, lastScan, totalScans, changeCount) => {
};

const wayBackHandler = async (url) => {
const { hostname } = parseTarget(url);
const skip = skipIfNonRoutable(hostname, 'Archive lookup');
if (skip) return skip;

// collapse=timestamp:8 returns one row per archived day, slashing payloads
// (Wikipedia: 25MB/373k rows -> 428KB/6k rows) without losing first/last/change counts
const cdxUrl =
Expand Down
3 changes: 3 additions & 0 deletions api/location.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { promises as dns } from 'dns';
import { skipIfNonRoutable } from './_common/target-scope.js';
import middleware from './_common/middleware.js';
import { parseTarget } from './_common/parse-target.js';
import { createLogger } from './_common/logger.js';
Expand Down Expand Up @@ -170,6 +171,8 @@ const resolveHost = async (hostname) => {
// Resolve geographic info for a host via a chain of providers with country enrichment
const locationHandler = async (url) => {
const { hostname } = parseTarget(url);
const skip = skipIfNonRoutable(hostname, 'IP geolocation lookup');
if (skip) return skip;
const ip = await resolveHost(hostname);
const geo = await lookupGeo(ip);
if (!geo) {
Expand Down
3 changes: 3 additions & 0 deletions api/rank.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import middleware from './_common/middleware.js';
import { httpGet } from './_common/http.js';
import { skipIfNonRoutable } from './_common/target-scope.js';
import { parseTarget } from './_common/parse-target.js';
import { upstreamError } from './_common/upstream.js';

const rankHandler = async (url) => {
const { hostname } = parseTarget(url);
const skip = skipIfNonRoutable(hostname, 'Tranco rank lookup');
if (skip) return skip;
const { TRANCO_USERNAME, TRANCO_API_KEY } = process.env;
const auth = TRANCO_API_KEY
? { auth: { username: TRANCO_USERNAME, password: TRANCO_API_KEY } }
Expand Down
3 changes: 3 additions & 0 deletions api/shodan.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import middleware from './_common/middleware.js';
import { httpGet } from './_common/http.js';
import { skipIfNonRoutable } from './_common/target-scope.js';
import { parseTarget } from './_common/parse-target.js';
import { requireEnv, upstreamError } from './_common/upstream.js';

Expand All @@ -8,6 +9,8 @@ const shodanHandler = async (url) => {
const auth = requireEnv('SHODAN_API_KEY', 'Shodan');
if (auth.skipped) return auth;
const { hostname } = parseTarget(url);
const skip = skipIfNonRoutable(hostname, 'Shodan lookup');
if (skip) return skip;
try {
const res = await httpGet(`https://api.shodan.io/shodan/host/${hostname}?key=${auth.value}`);
return res.data;
Expand Down
5 changes: 5 additions & 0 deletions api/threats.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import xml2js from 'xml2js';
import { skipIfNonRoutable } from './_common/target-scope.js';
import middleware from './_common/middleware.js';
import { httpPost } from './_common/http.js';
import { parseTarget } from './_common/parse-target.js';
Expand Down Expand Up @@ -79,6 +80,10 @@ const cloudmersive = async (url) => {

// Aggregate four threat-feed lookups; skip the card if every source failed
const threatsHandler = async (url) => {
const { hostname } = parseTarget(url);
const skip = skipIfNonRoutable(hostname, 'Threat intelligence lookup');
if (skip) return skip;

const sources = await Promise.all([
safeBrowsing(url),
urlHaus(url),
Expand Down