Malicious IP Navigation Guard

Stops top-level navigation when the destination resolves to an IP in a configured blocklist.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
        }
    }
})();