Is Shinigami Eyes safe?
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.
Who publishes itShinigami Eyes - no other listings under this identity, 5 shared hostnames
Shinigami Eyes - no other listings under this identity, 5 shared hostnames
What this publisher told the store about itself, and the other listings that told it the same thing.
Shared hosts - 5 hostnames
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
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.
What actually happens
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 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.
Captured request
The code expects a 200 response whose text body is SUCCESS.
Fields prepared for the vote submission
- Surrounding post HTMLIllustrative 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 URLhttps://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 ID7f6f6a0e-41a4-4c77-9b32-15eec3f11190
This lets repeated submissions from the same browser install be linked together.
- Account labelt-friendly
This records the label you chose for the selected account.
- Extension data versionsversion 165, bloomVersion 2024-10-01
This identifies which extension and bloom-filter versions were active when the label was submitted.
The code that does this
The content script selects a surrounding container and stores its outerHTML
Container selection by supported site
deobfuscated/content.jsfunction 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 message
deobfuscated/content.jsbrowser.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;});The code that does this
The background script queues the snippet and submits it to shini-api.xyz
Submission body construction and POST
deobfuscated/background.jsasync 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 queueing
deobfuscated/background.jsfunction 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 script
deobfuscated/background.jsbrowser.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); });});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.
- Location
- browser.storage.local overrides pending-submissions queue
{ "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"}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.
What it can do
Permissions this extension asks for, as declared in version 1.0.37. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to.
Read and change your data on every site you visit
*://*/*
Add items to the right-click menu
contextMenus
Store data in your browser
storage
Where it sends data
Destinations our analysis observed Shinigami Eyes contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.
- raw.githubusercontent.comwidely used
Shinigami Eyes sends data to raw.githubusercontent.com. A widely used service: 32 other extensions we have analyzed send data here.
- shini-api.xyz
Shinigami Eyes sends data to shini-api.xyz. One other extension we have analyzed sends data here.