mirror of
https://github.com/SickleSec/SnowEyes.git
synced 2026-10-24 17:04:31 +00:00
v0.2.7.3
This commit is contained in:
1 parent
9762c4fc94
commit
146019143a
4 files changed
+160
-124
No files matched your search
@@ -6,6 +6,17 @@
|
||||
|
||||
**雪瞳** 是一款用于检测和提取网页中敏感信息的 Chrome 浏览器扩展。帮助用户快速获取网页中的敏感信息,并进行分析和处理。
|
||||
|
||||
---
|
||||
## 一些使用技巧(方便好用)
|
||||
对于单个结果,鼠标悬浮即可看到来源,单击左键复制来源,ctrl+左键跳转来源链接,单击右键复制结果
|
||||
|
||||
---
|
||||
## v0.2.7.3更新
|
||||
1. 大幅优化搜索时的网页卡顿问题,体验更流畅
|
||||
2. 适配webpack网页js搜集,绝不放过每一处可能存在的js
|
||||
|
||||
和其他同类插件不同,雪瞳致力于更全面的js信息搜集,因此搜索时请多给雪瞳一些等待的时间,如果您发现了一些bug或是有更好的想法,欢迎提issue。
|
||||
|
||||
---
|
||||
## v0.2.7.2更新
|
||||
|
||||
@@ -17,17 +28,17 @@
|
||||
6. 深度扫描新增js文件路径猜解拼接,提高访问命中率
|
||||
7. 其他细节优化......
|
||||
|
||||
下个版本预计更新内容:当前版本问题优化,指纹添加,路径拼接优化,特殊网页处理,AI?,Maybe
|
||||
|
||||
注意:
|
||||
1. 深度扫描在一些情况下会出现很多js误匹配,此功能后续还需要多完善,正常情况下为了维持良好体验不建议使用
|
||||
2. 关于备案信息的查询建议注册登录接口盒子使用自己的Id和key,因为公开的接口查询有限制
|
||||
---
|
||||
下个版本预计更新内容:当前版本问题优化,指纹添加,路径拼接优化,特殊网页处理,AI?,Maybe
|
||||
|
||||
如果您有更好的第三方接口欢迎提issue,当然您愿意分享的话。
|
||||
|
||||
firfox版本后续更新,可能延迟一周左右。欢迎提issue,感谢支持!
|
||||
|
||||
---
|
||||
|
||||
## 功能特点
|
||||
|
||||
1. 实现多种敏感信息,指纹等检测分类
|
||||
@@ -37,7 +48,7 @@ firfox版本后续更新,可能延迟一周左右。欢迎提issue,感谢支
|
||||
## 使用详情
|
||||
|
||||
1. 将源码下载为压缩包文件后解压或直接clone
|
||||
2. 在浏览器扩展中打开开发者工具
|
||||
2. 在浏览器扩展中打开开发者模式
|
||||
3. 点击加载解压的扩展,选中解压的文件夹,即可在插件列表中使用插件
|
||||
|
||||
---
|
||||
|
||||
+51
-16
@@ -118,16 +118,12 @@ async function handleFetchRequest(request, sender, sendResponse) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 存储服务器指纹信息
|
||||
let serverFingerprints = new Map();
|
||||
|
||||
const analyticsDetected = {
|
||||
baidu: new Map(),
|
||||
yahoo: new Map(),
|
||||
google: new Map(),
|
||||
baidu: new Map(),
|
||||
yahoo: new Map(),
|
||||
google: new Map(),
|
||||
};
|
||||
// 统计服务检测处理函数
|
||||
function handleAnalyticsDetection(details, type) {
|
||||
if (analyticsDetected[type].get(details.tabId)) {
|
||||
return;
|
||||
@@ -141,8 +137,6 @@ function handleAnalyticsDetection(details, type) {
|
||||
description: FINGERPRINT_CONFIG.ANALYTICS[type].description,
|
||||
version: FINGERPRINT_CONFIG.ANALYTICS[type].version
|
||||
});
|
||||
|
||||
// 更新存储
|
||||
serverFingerprints.set(details.tabId, fingerprints);
|
||||
}
|
||||
|
||||
@@ -195,7 +189,6 @@ function processHeaders(headers, tabId) {
|
||||
const headerMap = new Map(
|
||||
headers.map(h => [h.name.toLowerCase(), h.value])
|
||||
);
|
||||
// 遍历所有指纹配置进行匹配
|
||||
for (const config of FINGERPRINT_CONFIG.HEADERS) {
|
||||
const headerValue = headerMap.get(config.header);
|
||||
if (headerValue) {
|
||||
@@ -253,19 +246,14 @@ function getFingerprints(tabId){
|
||||
serverFingerprints.set(tabId, fingerprints);
|
||||
return fingerprints;
|
||||
}
|
||||
// 修改监听器的处理方式
|
||||
chrome.webRequest.onHeadersReceived.addListener(
|
||||
async (details) => {
|
||||
// 只处理主文档请求,并且立即返回响应头
|
||||
if (details.type !== 'main_frame') return { responseHeaders: details.responseHeaders };
|
||||
if (!details.responseHeaders) return { responseHeaders: details.responseHeaders };
|
||||
|
||||
// 使用setTimeout将指纹处理放到下一个事件循环
|
||||
setTimeout(() => {
|
||||
const fingerprints = processHeaders(details.responseHeaders, details.tabId);
|
||||
serverFingerprints.set(details.tabId, fingerprints);
|
||||
|
||||
// 延迟处理cookies
|
||||
chrome.cookies.getAll({ url: details.url }, (cookies) => {
|
||||
if (cookies.length > 0) {
|
||||
const cookieNames = cookies.map(cookie => cookie.name).join(';');
|
||||
@@ -283,6 +271,47 @@ chrome.webRequest.onHeadersReceived.addListener(
|
||||
{ urls: ['<all_urls>'] },
|
||||
['responseHeaders']
|
||||
);
|
||||
function performRegexMatching(chunk, patterns, patternType) {
|
||||
const matches = [];
|
||||
let maxIterations = 10000;
|
||||
|
||||
try {
|
||||
for (const patternInfo of patterns) {
|
||||
const { pattern: patternString} = patternInfo;
|
||||
let regex;
|
||||
try {
|
||||
const match = patternString.match(/^\/(.+)\/([gimuy]*)$/);
|
||||
if (match) {
|
||||
regex = new RegExp(match[1], match[2]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`无效的正则表达式: ${patternString}`, e);
|
||||
continue;
|
||||
}
|
||||
let patternLastIndex = 0;
|
||||
let match;
|
||||
while ((match = regex.exec(chunk)) !== null) {
|
||||
if (regex.lastIndex <= patternLastIndex) {
|
||||
console.warn(`检测到可能的无限循环: ${patternType} Pattern - ${patternName}`);
|
||||
break;
|
||||
}
|
||||
patternLastIndex = regex.lastIndex;
|
||||
if (--maxIterations <= 0) {
|
||||
console.warn(`达到最大迭代次数: ${patternType}`);
|
||||
break;
|
||||
}
|
||||
matches.push({
|
||||
match: match[0],
|
||||
});
|
||||
}
|
||||
regex.lastIndex = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`${patternType} 匹配出错:`, e);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.to !== 'background') return false;
|
||||
@@ -317,7 +346,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
}
|
||||
case 'REGISTER_CONTENT': {
|
||||
const tabJs = Array.from(tabJsMap[sender.tab.id] || []);
|
||||
sendResponse({ tabJs: tabJs });
|
||||
sendResponse({ tabJs: tabJs, tabId: sender.tab.id });
|
||||
return true;
|
||||
}
|
||||
case 'UPDATE_BADGE': {
|
||||
@@ -328,6 +357,12 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
sendResponse({ tabId: sender.tab?.id });
|
||||
return true;
|
||||
}
|
||||
case 'REGEX_MATCH': {
|
||||
const { chunk, patterns, patternType } = request;
|
||||
const matches = performRegexMatching(chunk, patterns, patternType);
|
||||
sendResponse({ matches });
|
||||
return true;
|
||||
}
|
||||
case 'GET_SITE_ANALYSIS': {
|
||||
const domain = getRootDomain(request.domain);
|
||||
const tabId = request.tabId;
|
||||
|
||||
+93
-103
@@ -7,7 +7,7 @@ let maxDepth = 3;
|
||||
let tabJs = new Set();
|
||||
let isWhitelisted = false;
|
||||
let hostname = null;
|
||||
|
||||
let isUseWebpack = false;
|
||||
const tree = {};
|
||||
const jsQueue = [];
|
||||
const queueSet = new Set();
|
||||
@@ -27,6 +27,8 @@ async function initSettings() {
|
||||
chrome.runtime.sendMessage({ type: 'REGISTER_CONTENT', from: 'content', to: 'background' }, (response) => {
|
||||
if(isWhitelisted) return;
|
||||
tabJs = response.tabJs;
|
||||
currentTabId = response.tabId;
|
||||
getTabResults(currentTabId);
|
||||
tabJs.forEach(url => {
|
||||
enqueueJsUrl(url, 'background');
|
||||
});
|
||||
@@ -47,7 +49,6 @@ const waitForDependencies = () => {
|
||||
})();
|
||||
});
|
||||
};
|
||||
// 获取tabId
|
||||
const getTabId = () => {
|
||||
return new Promise(resolve => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_TAB_ID', from: 'content', to: 'background'}, response => {
|
||||
@@ -109,10 +110,10 @@ function enqueueJsUrl(url, source='page', basePath = '') {
|
||||
jsFileMap.set(fileName, filePath);
|
||||
queueSet.add(url);
|
||||
jsQueue.push(url);
|
||||
tabResults.get(currentTabId).jsFiles.set(url, "暂无法展示来源");
|
||||
processJsQueue();
|
||||
}
|
||||
}
|
||||
//分块处理
|
||||
function* splitIntoChunks(text) {
|
||||
if (text.length <= MAX_CHUNK_SIZE) {
|
||||
yield text;
|
||||
@@ -131,8 +132,6 @@ function* splitIntoChunks(text) {
|
||||
currentLines = [];
|
||||
currentSize = 0;
|
||||
}
|
||||
|
||||
// 对超长单行进行切割
|
||||
if (line.length > MAX_CHUNK_SIZE) {
|
||||
for (let i = 0; i < line.length; i += MAX_CHUNK_SIZE) {
|
||||
yield line.slice(i, i + MAX_CHUNK_SIZE);
|
||||
@@ -152,7 +151,7 @@ function* splitIntoChunks(text) {
|
||||
}
|
||||
}
|
||||
//匹配函数
|
||||
const matchPatterns = (chunk, isHtmlContent = false, url) => {
|
||||
const matchPatterns = async (chunk, isHtmlContent = false, url) => {
|
||||
const patterns = Object.entries(SCANNER_CONFIG.PATTERNS);
|
||||
const resultsSet = tabResults.get(currentTabId);
|
||||
let update = false;
|
||||
@@ -166,7 +165,6 @@ const matchPatterns = (chunk, isHtmlContent = false, url) => {
|
||||
|
||||
try {
|
||||
if (key === 'FINGER') {
|
||||
// 对每个模式进行匹配
|
||||
for (const {pattern: fingerPattern, name: fingerName, class: fingerClass, type: fingerType, description: fingerDescription, extType: fingerExtType, extName: fingerExtName} of pattern.patterns) {
|
||||
if (resultsSet.fingers.has(fingerClass)) continue;
|
||||
const matches = chunk.match(fingerPattern);
|
||||
@@ -188,7 +186,6 @@ const matchPatterns = (chunk, isHtmlContent = false, url) => {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 域名使用不同的匹配模式
|
||||
if (key === 'DOMAIN') {
|
||||
const domainPattern = isHtmlContent ? pattern : SCANNER_CONFIG.PATTERNS.DOMAIN_RESOURCE;
|
||||
while ((match = domainPattern.exec(chunk)) !== null) {
|
||||
@@ -209,7 +206,6 @@ const matchPatterns = (chunk, isHtmlContent = false, url) => {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// API模式特殊处理
|
||||
if (key === 'API') {
|
||||
const apiPattern = SCANNER_CONFIG.API.PATTERN;
|
||||
apiPattern.lastIndex = 0;
|
||||
@@ -230,58 +226,39 @@ const matchPatterns = (chunk, isHtmlContent = false, url) => {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key === 'CREDENTIALS') {
|
||||
// 对每个模式进行匹配
|
||||
for (const {pattern: credentialsPattern} of pattern.patterns) {
|
||||
let patternLastIndex = 0; // 每个模式独立的 lastIndex
|
||||
while ((match = credentialsPattern.exec(chunk)) !== null) {
|
||||
if (credentialsPattern.lastIndex <= patternLastIndex) {
|
||||
window.logger.warn(`检测到可能的无限循环: CREDENTIALS Pattern`);
|
||||
break;
|
||||
}
|
||||
patternLastIndex = credentialsPattern.lastIndex;
|
||||
|
||||
if (--maxIterations <= 0) {
|
||||
window.logger.warn(`达到最大迭代次数: CREDENTIALS`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (filter(match[0], url, resultsSet)) {
|
||||
update = true;
|
||||
}
|
||||
if (key === 'CREDENTIALS' || key === 'ID_KEY' || key === 'EMAIL') {
|
||||
let patterns = [];
|
||||
if (key === 'EMAIL') {
|
||||
patterns = [{"pattern": SCANNER_CONFIG.PATTERNS.EMAIL.toString()}];
|
||||
}else{
|
||||
patterns = pattern.patterns.map(p => ({
|
||||
pattern: p.pattern.toString(),
|
||||
}));
|
||||
}
|
||||
try {
|
||||
const response = await new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'REGEX_MATCH',
|
||||
from: 'content',
|
||||
to: 'background',
|
||||
chunk: chunk,
|
||||
patterns: patterns,
|
||||
patternType: key
|
||||
}, resolve);
|
||||
});
|
||||
|
||||
if (response && response.matches) {
|
||||
response.matches.forEach(({match}) => {
|
||||
if (filter(match, url, resultsSet)) {
|
||||
update = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
// 重置正则表达式的 lastIndex
|
||||
credentialsPattern.lastIndex = 0;
|
||||
} catch (e) {
|
||||
window.logger.error('CREDENTIALS匹配出错:', e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ID_KEY模式特殊处理
|
||||
if (key === 'ID_KEY') {
|
||||
// 对每个模式进行匹配
|
||||
for (const {pattern: idKeyPattern} of pattern.patterns) {
|
||||
let patternLastIndex = 0; // 每个模式独立的 lastIndex
|
||||
while ((match = idKeyPattern.exec(chunk)) !== null) {
|
||||
if (idKeyPattern.lastIndex <= patternLastIndex) {
|
||||
window.logger.warn(`检测到可能的无限循环: ID_KEY Pattern`);
|
||||
break;
|
||||
}
|
||||
patternLastIndex = idKeyPattern.lastIndex;
|
||||
|
||||
if (--maxIterations <= 0) {
|
||||
window.logger.warn(`达到最大迭代次数: ID_KEY`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (filter(match[0], url, resultsSet)) {
|
||||
update = true;
|
||||
}
|
||||
}
|
||||
// 重置正则表达式的 lastIndex
|
||||
idKeyPattern.lastIndex = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 其他模式使用exec
|
||||
while ((match = pattern.exec(chunk)) !== null) {
|
||||
if (pattern.lastIndex <= lastIndex) {
|
||||
window.logger.warn(`检测到可能的无限循环: ${pattern}`);
|
||||
@@ -305,12 +282,34 @@ const matchPatterns = (chunk, isHtmlContent = false, url) => {
|
||||
}
|
||||
return update;
|
||||
};
|
||||
// 收集页面的js
|
||||
const collectJsUrls = (content) => {
|
||||
const jsUrls = new Set();
|
||||
const baseUrl = window.location.origin;
|
||||
const jsPattern = /['"](?:[^'"]+\.(?:js)(?:\?[^\s'"]*)?)['"]/g;
|
||||
const matches = Array.from(content.matchAll(jsPattern))
|
||||
const chunkCodePattern = /("[a-z/]*")[+|()\[\]\{\}a-z]*\+"."\+{(?:"?[0-9a-z-]*"?:"?[0-9a-z-]{1,}"?,?){1,}}\[[a-z]\]\+".js"/i;
|
||||
const chunkJsPattern = /"?[0-9a-z-]*"?\s*:\s*"?[0-9a-z-]*"?/g;
|
||||
const chunkMatch = content.match(chunkCodePattern);
|
||||
if (chunkMatch) {
|
||||
isUseWebpack = true;
|
||||
let chunkBasePath = chunkMatch[1].slice(1, -1);
|
||||
if (!chunkBasePath.startsWith('/')) {
|
||||
chunkBasePath = '/' + chunkBasePath;
|
||||
}
|
||||
Array.from(chunkMatch[0].matchAll(chunkJsPattern)).forEach(chunkJsMatch => {
|
||||
let chunkId = chunkJsMatch[0].split(":")[0];
|
||||
if(chunkId.includes("\"")||chunkId.includes("\'")){
|
||||
chunkId = chunkId.slice(1, -1);
|
||||
}
|
||||
let chunkHash = chunkJsMatch[0].split(":")[1];
|
||||
if(chunkHash.includes("\"")||chunkHash.includes("\'")){
|
||||
chunkHash = chunkHash.slice(1, -1);
|
||||
}
|
||||
const chunkUrl = baseUrl + chunkBasePath + chunkId + '.' + chunkHash + '.js';
|
||||
jsUrls.add(chunkUrl);
|
||||
});
|
||||
}
|
||||
if (!isUseWebpack && deepScanEnabled) {
|
||||
const matches = Array.from(content.matchAll(jsPattern))
|
||||
.map(match => {
|
||||
const path = match[0].slice(1, -1);
|
||||
let url = null
|
||||
@@ -334,8 +333,8 @@ const collectJsUrls = (content) => {
|
||||
}
|
||||
})
|
||||
.filter(url => url !== null);
|
||||
|
||||
matches.forEach(url => jsUrls.add(url));
|
||||
matches.forEach(url => jsUrls.add(url));
|
||||
}
|
||||
return jsUrls;
|
||||
};
|
||||
//扫描函数
|
||||
@@ -344,7 +343,7 @@ async function scanSources(sources, isHtmlContent = false, url) {
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
for (const chunk of splitIntoChunks(source)) {
|
||||
let update = matchPatterns(chunk, isHtmlContent, url);
|
||||
let update = await matchPatterns(chunk, isHtmlContent, url);
|
||||
if (update) sendUpdate();
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
@@ -365,9 +364,8 @@ const debounceScan = () => {
|
||||
if (htmlContent) {
|
||||
scanSources([htmlContent], true, document.location.href);
|
||||
}
|
||||
}, 1000); // 2秒内的变化会被合并为一次扫描
|
||||
}, 1000);
|
||||
};
|
||||
//监听 DOM 变化
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (!dynamicScanEnabled) return;
|
||||
|
||||
@@ -383,38 +381,38 @@ const observer = new MutationObserver((mutations) => {
|
||||
debounceScan();
|
||||
}
|
||||
});
|
||||
// 初始化扫描
|
||||
function getTabResults(currentTabId){
|
||||
if (!tabResults.has(currentTabId)) {
|
||||
tabResults.set(currentTabId, {
|
||||
domains: new Map(),
|
||||
absoluteApis: new Map(),
|
||||
apis: new Map(),
|
||||
moduleFiles: new Map(),
|
||||
docFiles: new Map(),
|
||||
ips: new Map(),
|
||||
phones: new Map(),
|
||||
emails: new Map(),
|
||||
idcards: new Map(),
|
||||
jwts: new Map(),
|
||||
imageFiles: new Map(),
|
||||
jsFiles: new Map(),
|
||||
vueFiles: new Map(),
|
||||
urls: new Map(),
|
||||
githubUrls: new Map(),
|
||||
companies: new Map(),
|
||||
credentials: new Map(),
|
||||
cookies: new Map(),
|
||||
idKeys: new Map(),
|
||||
fingers: new Map(),
|
||||
progress: new Map()
|
||||
});
|
||||
}
|
||||
}
|
||||
async function initScan() {
|
||||
try {
|
||||
await waitForDependencies();
|
||||
if (!currentTabId) await getTabId();
|
||||
|
||||
if (!tabResults.has(currentTabId)) {
|
||||
tabResults.set(currentTabId, {
|
||||
domains: new Map(),
|
||||
absoluteApis: new Map(),
|
||||
apis: new Map(),
|
||||
moduleFiles: new Map(),
|
||||
docFiles: new Map(),
|
||||
ips: new Map(),
|
||||
phones: new Map(),
|
||||
emails: new Map(),
|
||||
idcards: new Map(),
|
||||
jwts: new Map(),
|
||||
imageFiles: new Map(),
|
||||
jsFiles: new Map(),
|
||||
vueFiles: new Map(),
|
||||
urls: new Map(),
|
||||
githubUrls: new Map(),
|
||||
companies: new Map(),
|
||||
credentials: new Map(),
|
||||
cookies: new Map(),
|
||||
idKeys: new Map(),
|
||||
fingers: new Map(),
|
||||
progress: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
getTabResults(currentTabId);
|
||||
window.logger.info('开始扫描...');
|
||||
Object.keys(tabResults.get(currentTabId)).forEach(key => {
|
||||
tabResults.get(currentTabId)[key].clear();
|
||||
@@ -463,7 +461,6 @@ const sendUpdate = () => {
|
||||
results: results,
|
||||
tabId: currentTabId,
|
||||
}).catch(() => {
|
||||
// 忽略消息发送失败的错误
|
||||
});
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
@@ -473,7 +470,6 @@ const sendUpdate = () => {
|
||||
results: results,
|
||||
tabId: currentTabId,
|
||||
}).catch(() => {
|
||||
// 忽略消息发送失败的错误
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.message !== 'Extension context invalidated.') {
|
||||
@@ -504,17 +500,11 @@ async function handleJsTask(url) {
|
||||
const response = await new Promise(resolve => {
|
||||
chrome.runtime.sendMessage({ type: 'FETCH_JS', url: url, from: 'content', to: 'background'}, resolve);
|
||||
});
|
||||
|
||||
if (response?.content) {
|
||||
//扫描js
|
||||
await scanSources([response.content], false, url);
|
||||
if(deepScanEnabled){
|
||||
// 收集新的js
|
||||
const newJsUrls = collectJsUrls(response.content);
|
||||
const basePath = getBasePath(url);
|
||||
if(newJsUrls){
|
||||
newJsUrls.forEach(jsUrl => enqueueJsUrl(jsUrl, 'page', basePath));
|
||||
}
|
||||
const newJsUrls = collectJsUrls(response.content);
|
||||
if(newJsUrls){
|
||||
newJsUrls.forEach(jsUrl => enqueueJsUrl(jsUrl, 'page', getBasePath(url)));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
|
||||
"name": "雪瞳",
|
||||
"version": "0.2.7.2",
|
||||
"version": "0.2.7.3",
|
||||
"description": "洞悉无形,守护无界",
|
||||
|
||||
"icons": {
|
||||
|
||||
Reference in new issue
Block a user