Stops top-level navigation when the destination resolves to an IP in a configured blocklist.
// ==UserScript==
// @name Malicious IP Navigation Guard
// @namespace https://example.invalid/
// @version 1.0.0
// @description Stops top-level navigation when the destination resolves to an IP in a configured blocklist.
// @match http://*/*
// @match https://*/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @connect *
// @author DNS/Marie
// @homepage https://github.com/MistressOfDNS/Malicious-IP-detector
// @homepageURL https://github.com/MistressOfDNS/Malicious-IP-detector
// @supportURL https://github.com/MistressOfDNS/Malicious-IP-detector/issues
// @license GPL-3.0 license
// ==/UserScript==
(() => {
'use strict';
const CONFIG = {
// Add more lists here. Each list must contain one IP address per line.
blocklists: [
{
id: 'romainmarcoux-outgoing-aa',
name: 'Romain Marcoux malicious outgoing IPs (AA)',
url: 'https://raw.githubusercontent.com/romainmarcoux/malicious-outgoing-ip/refs/heads/main/full-outgoing-ip-aa.txt',
enabled: true,
},
/*
{
id: 'another-list',
name: 'Another malicious IP list',
url: 'https://example.com/list.txt',
enabled: true,
},
*/
],
// Userscripts cannot read the IP selected by the browser's DNS resolver.
// This resolver is therefore used to obtain A and AAAA records.
dnsResolverUrl: 'https://cloudflare-dns.com/dns-query',
// The source list is updated frequently. One hour avoids downloading a
// roughly 1.8 MB list on every navigation.
listCacheMs: 60 * 60 * 1000,
// Recheck a hostname after this interval because DNS can change.
decisionCacheMs: 10 * 60 * 1000,
requestTimeoutMs: 20 * 1000,
// true: keep the page stopped if DNS or list retrieval fails.
// false: allow the page when checking fails.
failClosed: true,
};
const STORAGE_PREFIX = 'malicious-ip-navigation-guard:v1';
const BYPASS_KEY = `${STORAGE_PREFIX}:bypass-once`;
const currentUrl = location.href;
const hostname = normalizeHostname(location.hostname);
if (!hostname || !/^https?:$/.test(location.protocol)) {
return;
}
// A one-time bypass is consumed immediately and only applies to this exact URL.
const bypass = readValue(BYPASS_KEY, null);
if (bypass && bypass.url === currentUrl && bypass.expiresAt > Date.now()) {
deleteValue(BYPASS_KEY);
return;
}
if (bypass) {
deleteValue(BYPASS_KEY);
}
const decisionKey = `${STORAGE_PREFIX}:decision:${hostname}`;
const cachedDecision = readValue(decisionKey, null);
if (cachedDecision && cachedDecision.expiresAt > Date.now()) {
if (cachedDecision.blocked) {
stopPage();
renderBlocked(cachedDecision);
}
return;
}
// Stop a previously unseen or expired destination before its page resources
// can continue loading. A safe result is cached and the page is reloaded once.
stopPage();
renderChecking();
void checkDestination()
.then((decision) => {
writeValue(decisionKey, {
...decision,
expiresAt: Date.now() + CONFIG.decisionCacheMs,
});
if (decision.blocked) {
renderBlocked(decision);
return;
}
// The next execution sees the cached safe decision and allows loading.
location.reload();
})
.catch((error) => {
console.error('[Malicious IP Navigation Guard]', error);
if (CONFIG.failClosed) {
renderFailure(error);
return;
}
writeValue(decisionKey, {
blocked: false,
checkedIps: [],
expiresAt: Date.now() + Math.min(CONFIG.decisionCacheMs, 60 * 1000),
});
location.reload();
});
async function checkDestination() {
const checkedIps = await resolveDestinationIps(hostname);
if (checkedIps.length === 0) {
throw new Error(`No A or AAAA records were returned for ${hostname}.`);
}
const sources = CONFIG.blocklists.filter((source) => source.enabled);
if (sources.length === 0) {
throw new Error('No blocklists are enabled.');
}
const loadedLists = await Promise.all(sources.map(loadBlocklist));
for (const loaded of loadedLists) {
for (const ip of checkedIps) {
if (loaded.entries.has(ip)) {
return {
blocked: true,
hostname,
checkedIps,
matchedIp: ip,
sourceId: loaded.source.id,
sourceName: loaded.source.name,
sourceUrl: loaded.source.url,
usedStaleList: loaded.usedStaleList,
};
}
}
}
return {
blocked: false,
hostname,
checkedIps,
};
}
async function resolveDestinationIps(host) {
const directIp = normalizeIp(host);
if (directIp) {
return [directIp];
}
const queryTypes = [
{ name: 'A', number: 1 },
{ name: 'AAAA', number: 28 },
];
const results = await Promise.allSettled(
queryTypes.map(async (type) => {
const url = new URL(CONFIG.dnsResolverUrl);
url.searchParams.set('name', host);
url.searchParams.set('type', type.name);
const response = await requestJson(url.href, {
Accept: 'application/dns-json',
});
if (typeof response.Status === 'number' && response.Status !== 0) {
throw new Error(`DNS ${type.name} query returned status ${response.Status}.`);
}
return (response.Answer ?? [])
.filter((answer) => answer.type === type.number)
.map((answer) => normalizeIp(answer.data))
.filter(Boolean);
}),
);
const successful = results.filter((result) => result.status === 'fulfilled');
if (successful.length === 0) {
const reasons = results
.map((result) => result.status === 'rejected' ? result.reason?.message : '')
.filter(Boolean)
.join('; ');
throw new Error(`DNS lookup failed. ${reasons}`.trim());
}
return [...new Set(successful.flatMap((result) => result.value))];
}
async function loadBlocklist(source) {
validateSource(source);
const cacheKey = `${STORAGE_PREFIX}:list:${source.id}`;
const cached = readValue(cacheKey, null);
const cacheIsFresh = cached
&& typeof cached.text === 'string'
&& cached.fetchedAt + CONFIG.listCacheMs > Date.now();
if (cacheIsFresh) {
return {
source,
entries: parseIpList(cached.text),
usedStaleList: false,
};
}
try {
const text = await requestText(source.url);
const entries = parseIpList(text);
if (entries.size === 0) {
throw new Error('The downloaded list contained no valid IP addresses.');
}
writeValue(cacheKey, {
fetchedAt: Date.now(),
text,
});
return {
source,
entries,
usedStaleList: false,
};
} catch (error) {
// A stale cached list is safer and more useful than immediately failing.
if (cached && typeof cached.text === 'string') {
const entries = parseIpList(cached.text);
if (entries.size > 0) {
return {
source,
entries,
usedStaleList: true,
};
}
}
throw new Error(`${source.name}: ${error.message}`);
}
}
function parseIpList(text) {
const entries = new Set();
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#') || line.startsWith(';') || line.startsWith('!')) {
continue;
}
// Supports an IP followed by whitespace/comments. /32 and /128 are
// accepted as equivalent to a single host address.
const token = line.split(/\s+/, 1)[0];
const ip = normalizeIp(token);
if (ip) {
entries.add(ip);
}
}
return entries;
}
function normalizeHostname(value) {
return String(value ?? '')
.trim()
.replace(/^\[/, '')
.replace(/\]$/, '')
.replace(/\.$/, '')
.toLowerCase();
}
function normalizeIp(value) {
let candidate = String(value ?? '')
.trim()
.replace(/^\[/, '')
.replace(/\]$/, '')
.toLowerCase();
candidate = candidate.replace(/\/(32|128)$/, '');
const ipv4Match = candidate.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const octets = ipv4Match.slice(1).map(Number);
if (octets.every((octet) => octet >= 0 && octet <= 255)) {
return octets.join('.');
}
return null;
}
// Basic IPv6 validation. This intentionally preserves the resolver/list
// representation; the supplied list currently consists of IPv4 entries.
if (candidate.includes(':') && /^[0-9a-f:.]+$/i.test(candidate)) {
return candidate;
}
return null;
}
function validateSource(source) {
if (!source || !source.id || !source.name || !source.url) {
throw new Error('Every blocklist needs a unique id, name, and URL.');
}
let parsed;
try {
parsed = new URL(source.url);
} catch {
throw new Error(`Invalid blocklist URL: ${source.url}`);
}
if (parsed.protocol !== 'https:') {
throw new Error(`Blocklist URLs must use HTTPS: ${source.url}`);
}
}
function requestText(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: CONFIG.requestTimeoutMs,
headers: {
Accept: 'text/plain, */*;q=0.1',
'Cache-Control': 'no-cache',
},
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response.responseText);
} else {
reject(new Error(`HTTP ${response.status} while downloading ${url}`));
}
},
onerror: () => reject(new Error(`Network error while downloading ${url}`)),
ontimeout: () => reject(new Error(`Timed out while downloading ${url}`)),
});
});
}
async function requestJson(url, headers = {}) {
const text = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: CONFIG.requestTimeoutMs,
headers,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response.responseText);
} else {
reject(new Error(`HTTP ${response.status} while requesting ${url}`));
}
},
onerror: () => reject(new Error(`Network error while requesting ${url}`)),
ontimeout: () => reject(new Error(`Timed out while requesting ${url}`)),
});
});
try {
return JSON.parse(text);
} catch {
throw new Error(`Invalid JSON returned by ${url}`);
}
}
function stopPage() {
try {
window.stop();
} catch {
// Ignore browsers that reject window.stop() at document-start.
}
const root = document.documentElement ?? document.appendChild(document.createElement('html'));
root.replaceChildren();
root.style.background = '#111318';
root.style.colorScheme = 'dark';
}
function renderChecking() {
renderScreen({
title: 'Checking destination',
message: `Resolving ${hostname} and comparing it with ${enabledListCount()} configured blocklist(s).`,
details: [
['Destination', hostname],
['Status', 'The page has been stopped while the check runs.'],
],
buttons: [],
});
}
function renderBlocked(decision) {
const staleNote = decision.usedStaleList
? 'The live list could not be downloaded, so a cached copy was used.'
: 'The current cached/downloaded list was used.';
renderScreen({
title: 'Potentially malicious destination blocked',
message: 'This site resolved to an IP address found in a configured malicious-IP list.',
details: [
['Site', decision.hostname ?? hostname],
['Resolved IPs', (decision.checkedIps ?? []).join(', ') || 'Unknown'],
['Matched IP', decision.matchedIp ?? 'Unknown'],
['Matched list', decision.sourceName ?? 'Unknown'],
['List status', staleNote],
['Requested URL', currentUrl],
],
buttons: [
{
label: 'Ignore and continue once',
primary: true,
action: ignoreAndContinue,
},
{
label: 'Go back',
action: goBack,
},
],
});
}
function renderFailure(error) {
renderScreen({
title: 'Destination check failed',
message: 'The page remains stopped because the DNS lookup or a blocklist could not be checked.',
details: [
['Site', hostname],
['Error', error?.message || String(error)],
['Requested URL', currentUrl],
],
buttons: [
{
label: 'Ignore and continue once',
primary: true,
action: ignoreAndContinue,
},
{
label: 'Go back',
action: goBack,
},
],
});
}
function renderScreen({ title, message, details, buttons }) {
const root = document.documentElement ?? document.appendChild(document.createElement('html'));
root.replaceChildren();
const head = document.createElement('head');
const meta = document.createElement('meta');
meta.name = 'viewport';
meta.content = 'width=device-width, initial-scale=1';
head.appendChild(meta);
const style = document.createElement('style');
style.textContent = `
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 24px;
background: #111318;
color: #f4f6f8;
font: 16px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(760px, 100%);
border: 1px solid #424854;
border-radius: 14px;
background: #1b1f27;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.banner {
padding: 18px 22px;
border-bottom: 1px solid #424854;
background: #332027;
}
h1 { margin: 0; font-size: 1.35rem; line-height: 1.25; }
.content { padding: 22px; }
p { margin: 0 0 18px; color: #d6dae0; }
dl {
display: grid;
grid-template-columns: minmax(120px, 170px) 1fr;
gap: 9px 16px;
margin: 0;
}
dt { color: #aeb5bf; font-weight: 600; }
dd { margin: 0; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; }
button {
appearance: none;
border: 1px solid #697180;
border-radius: 8px;
padding: 10px 15px;
background: #262b35;
color: #f4f6f8;
font: inherit;
font-weight: 650;
cursor: pointer;
}
button:hover { background: #323844; }
button.primary { border-color: #b85b68; background: #8f3544; }
button.primary:hover { background: #a84152; }
@media (max-width: 560px) {
dl { grid-template-columns: 1fr; gap: 2px; }
dd { margin-bottom: 10px; }
}
`;
head.appendChild(style);
const body = document.createElement('body');
const main = document.createElement('main');
const banner = document.createElement('div');
banner.className = 'banner';
const heading = document.createElement('h1');
heading.textContent = title;
banner.appendChild(heading);
const content = document.createElement('div');
content.className = 'content';
const paragraph = document.createElement('p');
paragraph.textContent = message;
content.appendChild(paragraph);
const definitionList = document.createElement('dl');
for (const [label, value] of details) {
const term = document.createElement('dt');
term.textContent = label;
const description = document.createElement('dd');
description.textContent = value;
definitionList.append(term, description);
}
content.appendChild(definitionList);
if (buttons.length > 0) {
const actions = document.createElement('div');
actions.className = 'actions';
for (const buttonSpec of buttons) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = buttonSpec.label;
if (buttonSpec.primary) {
button.className = 'primary';
}
button.addEventListener('click', buttonSpec.action, { once: true });
actions.appendChild(button);
}
content.appendChild(actions);
}
main.append(banner, content);
body.appendChild(main);
root.append(head, body);
document.title = title;
}
function ignoreAndContinue() {
writeValue(BYPASS_KEY, {
url: currentUrl,
expiresAt: Date.now() + 2 * 60 * 1000,
});
location.reload();
}
function goBack() {
if (history.length > 1) {
history.back();
return;
}
location.replace('about:blank');
}
function enabledListCount() {
return CONFIG.blocklists.filter((source) => source.enabled).length;
}
function readValue(key, fallback) {
try {
return GM_getValue(key, fallback);
} catch (error) {
console.warn(`[Malicious IP Navigation Guard] Could not read ${key}:`, error);
return fallback;
}
}
function writeValue(key, value) {
try {
GM_setValue(key, value);
} catch (error) {
console.warn(`[Malicious IP Navigation Guard] Could not write ${key}:`, error);
}
}
function deleteValue(key) {
try {
GM_deleteValue(key);
} catch (error) {
console.warn(`[Malicious IP Navigation Guard] Could not delete ${key}:`, error);
}
}
})();