Malicious IP Navigation Guard

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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