Is Shinigami Eyes safe?

Medium risk

Shinigami Eyes collects surrounding post DOM from social media pages and transmits it to shini-api.xyz when a user labels an account.

When a user applies a label (mark) to an account on Twitter, Reddit, Mastodon, or similar sites, the extension captures the outerHTML of the enclosing post container—which can include content from unrelated users—and sends it to shini-api.xyz along with a persistent installation ID, the target identifier, and the page URL. The extension also periodically fetches remote configuration from GitHub and loads bloom filter binaries from URLs specified in that configuration, with no cryptographic integrity check on the downloaded binary data.

Shinigami Eyesv1.0.37Firefox Add-ons
45Risk

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

Shinigami Eyes Sends Post HTML With Account Labels

Labeling a social media account makes Shinigami Eyes read the surrounding post or thread container and include its HTML in the vote submission to shini-api.xyz.

The code places the container's outerHTML into a snippet field before upload.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You label a social media account from the right-click menu.

This happens after the extension has been accepted and the selected link can be resolved to an account identifier.

The extension did this

The extension reads the surrounding post or thread HTML and submits it with the label.

The same request also carries the current page URL, installation ID, label choice, extension version, and bloom-filter version.

02EvidenceNETWORK CAPTURE
Captured request
POSThttps://shini-api.xyz/submit-vote
The code expects a 200 response whose text body is SUCCESS.
03EvidenceFIELD TABLE
Fields prepared for the vote submission
FieldValueWhy it matters
Surrounding post HTML
Illustrative Reddit container: <div class="Comment"><a href="/user/alex">u/alex</a><p>comment text</p></div>This can include the post, comment, or thread container around the account you label, including content from other people on the page.
Current page URL
https://www.reddit.com/r/all/comments/1b2c3d/thread_title/This shows which social media page or thread was open when you submitted the label.
Installation ID
7f6f6a0e-41a4-4c77-9b32-15eec3f11190This lets repeated submissions from the same browser install be linked together.
Account label
t-friendlyThis records the label you chose for the selected account.
Extension data versions
version 165, bloomVersion 2024-10-01This identifies which extension and bloom-filter versions were active when the label was submitted.
04EvidenceCODE COMPARE
The code that does this

The content script selects a surrounding container and stores its outerHTML

What it actually does
Container selection by supported sitedeobfuscated/content.js
function getSnippet(node) {
    try {
        return getSnippetImpl(node);
    }
    catch (e) {
        console.warn("Could not obtain snippet: " + e);
        return null;
    }
}
function getSnippetImpl(node) {
    var _a, _b, _c;
    if (hostname == 'facebook.com') {
        const pathname = window.location.pathname;
        const isPhotoPage = pathname.startsWith('/photo') || pathname.includes('/photos/') || pathname.startsWith('/video') || pathname.includes('/videos/');
        if (isPhotoPage) {
            const sidebar = document.querySelector('[role=complementary]');
            if (sidebar)
                return sidebar.parentElement;
        }
        const isSearchPage = pathname.startsWith('/search/');
        return getMatchingAncestor(node, x => {
            if (x.getAttribute('role') == 'article' && (isSearchPage || x.getAttribute('aria-labelledby')))
                return true;
            var dataset = x.dataset;
            if (!dataset)
                return false;
            if (dataset.ftr)
                return true;
            if (dataset.highlightTokens)
                return true;
            if (dataset.gt && dataset.vistracking)
                return true;
            return false;
        });
    }
    if (hostname == 'reddit.com')
        return getMatchingAncestorByCss(node, '.scrollerItem, .thing, .Comment');
    if (hostname == 'twitter.com')
        return getMatchingAncestorByCss(node, '.stream-item, .permalink-tweet-container, article');
    if (hostname == 'disqus.com')
        return getMatchingAncestorByCss(node, '.post-content');
    if (hostname == 'medium.com')
        return getMatchingAncestorByCss(node, '.streamItem, .streamItemConversationItem');
    if (hostname == 'youtube.com')
        return getMatchingAncestorByCss(node, 'ytd-comment-renderer, ytd-video-secondary-info-renderer');
    if (hostname == 'tumblr.com')
        return getMatchingAncestor(node, x => (x.dataset && !!(x.dataset.postId || x.dataset.id)) || x.classList.contains('post'));
    if (hostname == 'threads.net') {
        if (location.pathname.includes('/post/')) {
            return getOutermostMatchingAncestor(node, x => getAbsoluteOffsetTop(x) > 30);
        }
        else {
            return getOutermostMatchingAncestor(node, x => x.dataset.pressableContainer == 'true');
        }
    }
    if (hostname == 'bsky.app') {
        if (location.pathname.includes('/post/')) {
            return (_b = (_a = getOutermostMatchingAncestor(node, x => { var _a; return (_a = x.dataset.testid) === null || _a === void 0 ? void 0 : _a.startsWith('postThreadItem-by-'); })) === null || _a === void 0 ? void 0 : _a.parentElement) === null || _b === void 0 ? void 0 : _b.parentElement;
        }
        else {
            return getOutermostMatchingAncestor(node, x => { var _a; return (_a = x.dataset.testid) === null || _a === void 0 ? void 0 : _a.startsWith('feedItem-by-'); });
        }
    }
    if (isMastodon)
        return (_c = (/\/\d+$/.test(location.pathname) ? getMatchingAncestorByCss(node, '.scrollable') : null)) !== null && _c !== void 0 ? _c : getMatchingAncestorByCss(node, '.status, article, .detailed-status__wrapper, .status__wrapper-reply');
    return null;
}
Message handler adds snippet.outerHTML to the vote messagedeobfuscated/content.js
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
    if (message.updateAllLabels || message.confirmSetLabel) {
        displayConfirmation(message.confirmSetIdentifier, message.confirmSetLabel, message.badIdentifierReason, message.confirmSetUrl, null);
        updateAllLabels(true);
        return undefined;
    }
    message.contextPage = window.location.href;
    const originalTarget = lastRightClickedElement;
    let target = originalTarget; // message.elementId ? browser.menus.getTargetElement(message.elementId) : null;
    while (target) {
        if (target instanceof HTMLAnchorElement)
            break;
        target = target.parentElement;
    }
    if (target && target.href != message.url)
        target = null;
    var identifier = target ? getIdentifier(target, originalTarget) : getIdentifier(message.url);
    if (!identifier) {
        displayConfirmation(null, 'bad-identifier', null, message.url, target);
        return undefined;
    }
    (async () => {
        var _a, _b;
        message.identifier = identifier;
        if (identifier.startsWith('facebook.com/'))
            message.secondaryIdentifier = getIdentifier(message.url);
        var snippet = getSnippet(target);
        message.linkId = ++lastGeneratedLinkId;
        if (target)
            target.setAttribute('shinigami-eyes-link-id', '' + lastGeneratedLinkId);
        if (hostname == 'twitter.com') {
            try {
                const twitterUserName = (_a = captureRegex(identifier, /^twitter\.com\/(.*)$/)) === null || _a === void 0 ? void 0 : _a.toLowerCase();
                if (twitterUserName) {
                    const request = {
                        linkId: message.linkId,
                        wantIdForScreenName: twitterUserName
                    };
                    const response = await findTwitterNumericIdsFirefox(request);
                    const twitterMapping = (_b = response.mappings) === null || _b === void 0 ? void 0 : _b.filter(x => { var _a; return twitterUserName == ((_a = x.userName) === null || _a === void 0 ? void 0 : _a.toLowerCase()); })[0];
                    if (twitterMapping)
                        message.secondaryIdentifier = 'twitter.com/i/user/' + twitterMapping.numericId;
                }
            }
            catch (error) {
                console.warn(error);
            }
        }
        message.snippet = snippet ? snippet.outerHTML : null;
        var debugClass = 'shinigami-eyes-debug-snippet-highlight';
        if (snippet && message.debug) {
            snippet.classList.add(debugClass);
            if (message.debug <= 1)
                setTimeout(() => snippet.classList.remove(debugClass), 1500);
        }
        sendResponse(message);
    })();
    return true;
});
05EvidenceCODE COMPARE
The code that does this

The background script queues the snippet and submits it to shini-api.xyz

What it actually does
Submission body construction and POSTdeobfuscated/background.js
async function submitPendingRatings() {
    const submitted = getPendingSubmissions().filter(x => !submissionsBeingSubmitted.has(x));
    for (const entry of submitted) {
        submissionsBeingSubmitted.add(entry);
    }
    let plainRequest = {
        installationId: installationId,
        lastError: lastSubmissionError,
        entries: submitted
    };
    console.log('Submitting request:');
    console.log(plainRequest);
    let actualRequest = plainRequest;
    if (!disableAsymmetricEncryption) {
        try {
            actualRequest = await encryptSubmission(plainRequest);
        }
        catch (e) {
            // If something goes wrong, fall back to the old behavior (of course, we still have HTTPS).
            // While the above encryption process has been tested on both Chromium- and Gecko-based browsers,
            // the real world behavior might be different.
            // If no significant issues appear, this catch clause will be removed in a subsequent version of Shinigami Eyes.
            actualRequest.encryptionError = e + '';
        }
    }
    lastSubmissionError = null;
    try {
        const controller = new AbortController();
        setTimeout(() => controller.abort(), 90000);
        const response = await fetch('https://shini-api.xyz/submit-vote', {
            body: JSON.stringify(actualRequest),
            method: 'POST',
            credentials: 'omit',
            signal: controller.signal
        });
        if (response.status != 200)
            throw ('HTTP status: ' + response.status);
        const result = await response.text();
        if (result != 'SUCCESS')
            throw 'Bad response: ' + ('' + result).substring(0, 20);
        overrides[PENDING_SUBMISSIONS] = getPendingSubmissions().filter(x => submitted.indexOf(x) == -1);
        browser.storage.local.set({ overrides: overrides });
    }
    catch (e) {
        lastSubmissionError = '' + e;
    }
    finally {
        for (const entry of submitted) {
            submissionsBeingSubmitted.delete(entry);
        }
    }
}
Snippet trimming and queueingdeobfuscated/background.js
function saveLabel(response) {
    if (accepted) {
        if (!getPendingSubmissions()) {
            overrides[PENDING_SUBMISSIONS] = Object.getOwnPropertyNames(overrides)
                .map(x => { return { identifier: x, label: overrides[x] }; });
        }
        overrides[response.identifier] = response.mark;
        if (response.secondaryIdentifier && !response.secondaryIdentifier.startsWith('twitter.com/i/user/'))
            overrides[response.secondaryIdentifier] = response.mark;
        browser.storage.local.set({ overrides: overrides });
        response.version = CURRENT_VERSION;
        response.bloomVersion = bloomFilters.bloomVersion;
        response.submissionId = (Math.random() + '').replace('.', '');
        let totalSize = 0;
        for (const entry of getPendingSubmissions()) {
            if (entry.snippet)
                totalSize += entry.snippet.length;
        }
        if (totalSize > 2000000) {
            for (const entry of getPendingSubmissions()) {
                entry.snippet = null;
                entry.trimmed = true;
            }
        }
        if (response.snippet && response.snippet.length > 10000000) {
            response.snippet = null;
            response.trimmed = true;
        }
        getPendingSubmissions().push(response);
        submitPendingRatings();
        //console.log(response);
        sendMessageToContent(response.tabId, response.frameId, {
            updateAllLabels: true,
            confirmSetIdentifier: response.identifier,
            confirmSetUrl: response.url,
            confirmSetLabel: response.mark || 'none'
        });
        //browser.tabs.executeScript(response.tabId, {code: 'updateAllLabels()'});
        return;
    }
    uncommittedResponse = response;
    openHelp();
}
Context-menu label action sent to the content scriptdeobfuscated/background.js
browser.contextMenus.onClicked.addListener(function (info, tab) {
    if (info.menuItemId == 'help') {
        openHelp();
        return;
    }
    if (info.menuItemId == 'options') {
        openOptions();
        return;
    }
    const tabId = tab.id;
    const frameId = info.frameId;
    var label = info.menuItemId.substring('mark-'.length);
    if (label == 'none')
        label = '';
    browser.tabs.sendMessage(tabId, {
        mark: label,
        url: info.linkUrl,
        tabId: tabId,
        frameId: frameId,
        // elementId: info.targetElementId,
        debug: overrides.debug
    }, { frameId: frameId }, response => {
        if (!response || !response.identifier) {
            return;
        }
        if (response.mark) {
            if (badIdentifiers[response.identifier]) {
                sendMessageToContent(tabId, frameId, {
                    confirmSetIdentifier: response.identifier,
                    confirmSetUrl: response.url,
                    confirmSetLabel: 'bad-identifier',
                    badIdentifierReason: badIdentifiersReasons[response.identifier]
                });
                return;
            }
            if (response.secondaryIdentifier && badIdentifiers[response.secondaryIdentifier])
                response.secondaryIdentifier = null;
        }
        response.tabId = tabId;
        response.frameId = frameId;
        saveLabel(response);
    });
});
06EvidenceSTORAGE DUMP
What's stored on your device

Before upload, the extension stores the label submission locally with the page context and the surrounding HTML it selected.

Locationbrowser.storage.local overrides pending-submissions queue
Contents (JSON)
{
  "mark": "t-friendly",
  "snippet": "Illustrative value based on the selected Reddit container: <div class=\"Comment\"><a href=\"/user/alex\">u/alex</a><p>comment text</p></div>",
  "version": "CURRENT_VERSION",
  "identifier": "reddit.com/user/alex",
  "contextPage": "https://www.reddit.com/r/all/comments/1b2c3d/thread_title/",
  "bloomVersion": "bloomFilters.bloomVersion",
  "submissionId": "06519771462291288"
}
07EvidenceTHIRD PARTY LIST
External service receiving the vote submission
  • shini-api.xyz

    Receives POST /submit-vote submissions containing queued account labels, installation ID, page URL, and any snippet field kept by the trimming checks.

Data recipients

shini-api.xyzraw.githubusercontent.com
Updated 17 September 2026amo-998734