@anyul/koishi-plugin-rss 5.2.1 → 5.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/error-handler.js +2 -5
- package/lib/commands/index.d.ts +17 -1
- package/lib/commands/index.js +388 -2
- package/lib/commands/subscription-edit.d.ts +7 -0
- package/lib/commands/subscription-edit.js +177 -0
- package/lib/commands/subscription-management.d.ts +12 -0
- package/lib/commands/subscription-management.js +176 -0
- package/lib/commands/utils.d.ts +13 -1
- package/lib/commands/utils.js +43 -2
- package/lib/config.js +19 -0
- package/lib/core/ai.d.ts +16 -2
- package/lib/core/ai.js +73 -6
- package/lib/core/feeder.d.ts +1 -1
- package/lib/core/feeder.js +238 -125
- package/lib/core/item-processor.d.ts +5 -0
- package/lib/core/item-processor.js +66 -136
- package/lib/core/notification-queue.d.ts +2 -0
- package/lib/core/notification-queue.js +80 -33
- package/lib/core/parser.js +12 -0
- package/lib/core/renderer.d.ts +15 -0
- package/lib/core/renderer.js +105 -16
- package/lib/index.js +28 -784
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types.d.ts +24 -0
- package/lib/utils/common.js +52 -3
- package/lib/utils/error-handler.d.ts +8 -0
- package/lib/utils/error-handler.js +27 -0
- package/lib/utils/error-tracker.js +24 -8
- package/lib/utils/fetcher.js +68 -9
- package/lib/utils/logger.d.ts +4 -2
- package/lib/utils/logger.js +144 -6
- package/lib/utils/media.js +3 -6
- package/lib/utils/sanitizer.d.ts +58 -0
- package/lib/utils/sanitizer.js +227 -0
- package/lib/utils/security.d.ts +75 -0
- package/lib/utils/security.js +312 -0
- package/lib/utils/structured-logger.js +3 -20
- package/package.json +2 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.sanitizeHtml = sanitizeHtml;
|
|
40
|
+
exports.sanitizeToText = sanitizeToText;
|
|
41
|
+
exports.sanitizeImageUrls = sanitizeImageUrls;
|
|
42
|
+
exports.sanitizeLinks = sanitizeLinks;
|
|
43
|
+
exports.createSanitizer = createSanitizer;
|
|
44
|
+
const isomorphic_dompurify_1 = __importDefault(require("isomorphic-dompurify"));
|
|
45
|
+
const cheerio = __importStar(require("cheerio"));
|
|
46
|
+
/**
|
|
47
|
+
* 允许的 HTML 标签列表
|
|
48
|
+
*/
|
|
49
|
+
const ALLOWED_TAGS = [
|
|
50
|
+
// 基础标签
|
|
51
|
+
'p', 'br', 'hr',
|
|
52
|
+
// 文本格式化
|
|
53
|
+
'b', 'i', 'u', 'strong', 'em', 's', 'strike', 'del', 'sub', 'sup',
|
|
54
|
+
// 标题
|
|
55
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
56
|
+
// 链接和媒体
|
|
57
|
+
'a', 'img', 'video', 'audio', 'source', 'track',
|
|
58
|
+
// 列表
|
|
59
|
+
'ul', 'ol', 'li',
|
|
60
|
+
// 容器
|
|
61
|
+
'div', 'span', 'section', 'article', 'header', 'footer', 'nav', 'main',
|
|
62
|
+
'aside', 'figure', 'figcaption',
|
|
63
|
+
// 引用和代码
|
|
64
|
+
'blockquote', 'pre', 'code', 'details', 'summary',
|
|
65
|
+
// 表格
|
|
66
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'caption',
|
|
67
|
+
// 语义化标签
|
|
68
|
+
'time', 'mark'
|
|
69
|
+
];
|
|
70
|
+
/**
|
|
71
|
+
* 允许的 HTML 属性列表
|
|
72
|
+
*/
|
|
73
|
+
const ALLOWED_ATTR = [
|
|
74
|
+
// 全局属性
|
|
75
|
+
'class', 'style', 'id', 'title', 'lang', 'dir',
|
|
76
|
+
// 链接属性
|
|
77
|
+
'href', 'target', 'rel',
|
|
78
|
+
// 媒体属性
|
|
79
|
+
'src', 'alt', 'width', 'height', 'poster', 'controls', 'autoplay', 'loop', 'muted', 'preload',
|
|
80
|
+
// 表格属性
|
|
81
|
+
'colspan', 'rowspan',
|
|
82
|
+
// time 标签属性
|
|
83
|
+
'datetime',
|
|
84
|
+
// details 标签属性
|
|
85
|
+
'open'
|
|
86
|
+
];
|
|
87
|
+
/**
|
|
88
|
+
* 配置 DOMPurify
|
|
89
|
+
*/
|
|
90
|
+
const DOMPURIFY_CONFIG = {
|
|
91
|
+
ALLOWED_TAGS,
|
|
92
|
+
ALLOWED_ATTR,
|
|
93
|
+
ALLOW_DATA_ATTR: false, // 禁止 data-* 属性
|
|
94
|
+
ALLOWED_URI_REGEXP: /^(?:(?:https?):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i,
|
|
95
|
+
ADD_ATTR: ['target'], // 允许 target 属性
|
|
96
|
+
FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'input', 'button', 'object', 'embed'],
|
|
97
|
+
FORBID_ATTR: [
|
|
98
|
+
// 事件处理器
|
|
99
|
+
'onclick', 'oncontextmenu', 'ondblclick', 'onmousedown', 'onmouseenter',
|
|
100
|
+
'onmouseleave', 'onmousemove', 'onmouseover', 'onmouseout', 'onmouseup',
|
|
101
|
+
'onkeydown', 'onkeypress', 'onkeyup', 'onabort', 'onbeforeunload',
|
|
102
|
+
'onerror', 'onhashchange', 'onload', 'onpageshow', 'onpagehide',
|
|
103
|
+
'onresize', 'onscroll', 'onunload', 'onblur', 'onchange', 'onfocus',
|
|
104
|
+
'oninput', 'oninvalid', 'onreset', 'onsearch', 'onselect', 'onsubmit',
|
|
105
|
+
// JavaScript URL
|
|
106
|
+
'javascript:', 'vbscript:', 'data:',
|
|
107
|
+
// expr 表达式
|
|
108
|
+
'expr', 'xpression'
|
|
109
|
+
]
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* 清理 HTML 内容,移除潜在恶意代码
|
|
113
|
+
*
|
|
114
|
+
* @param html - 要清理的 HTML 字符串
|
|
115
|
+
* @param config - 可选的额外配置
|
|
116
|
+
* @returns 清理后的 HTML 字符串
|
|
117
|
+
*/
|
|
118
|
+
function sanitizeHtml(html, config) {
|
|
119
|
+
if (!html)
|
|
120
|
+
return '';
|
|
121
|
+
// 合并配置
|
|
122
|
+
const mergeConfig = {
|
|
123
|
+
...DOMPURIFY_CONFIG,
|
|
124
|
+
...config
|
|
125
|
+
};
|
|
126
|
+
return isomorphic_dompurify_1.default.sanitize(html, mergeConfig);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 清理并返回纯文本(用于需要提取文本的场景)
|
|
130
|
+
*
|
|
131
|
+
* @param html - 要清理的 HTML 字符串
|
|
132
|
+
* @returns 清理后的纯文本
|
|
133
|
+
*/
|
|
134
|
+
function sanitizeToText(html) {
|
|
135
|
+
if (!html)
|
|
136
|
+
return '';
|
|
137
|
+
// 先清理 HTML
|
|
138
|
+
const cleanHtml = sanitizeHtml(html);
|
|
139
|
+
// 使用 cheerio 提取纯文本
|
|
140
|
+
const $ = cheerio.load(cleanHtml);
|
|
141
|
+
return $.text();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 清理 HTML 中的图片 URL
|
|
145
|
+
*
|
|
146
|
+
* @param html - 包含图片的 HTML 字符串
|
|
147
|
+
* @returns 清理后的 HTML(图片 URL 已验证)
|
|
148
|
+
*/
|
|
149
|
+
function sanitizeImageUrls(html) {
|
|
150
|
+
if (!html)
|
|
151
|
+
return '';
|
|
152
|
+
// 使用 DOMPurify 清理,但只允许 img 标签
|
|
153
|
+
const imgOnlyConfig = {
|
|
154
|
+
...DOMPURIFY_CONFIG,
|
|
155
|
+
ALLOWED_TAGS: ['img'],
|
|
156
|
+
ALLOWED_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'title']
|
|
157
|
+
};
|
|
158
|
+
return isomorphic_dompurify_1.default.sanitize(html, imgOnlyConfig);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 清理链接(a 标签)
|
|
162
|
+
*
|
|
163
|
+
* @param html - 包含链接的 HTML 字符串
|
|
164
|
+
* @returns 清理后的 HTML(链接已安全化)
|
|
165
|
+
*/
|
|
166
|
+
function sanitizeLinks(html) {
|
|
167
|
+
if (!html)
|
|
168
|
+
return '';
|
|
169
|
+
// 使用 DOMPurify 清理,但只允许 a 标签
|
|
170
|
+
const linkOnlyConfig = {
|
|
171
|
+
...DOMPURIFY_CONFIG,
|
|
172
|
+
ALLOWED_TAGS: ['a'],
|
|
173
|
+
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'title']
|
|
174
|
+
};
|
|
175
|
+
// 添加链接安全化:添加 rel="noopener noreferrer"
|
|
176
|
+
let cleanHtml = isomorphic_dompurify_1.default.sanitize(html, linkOnlyConfig);
|
|
177
|
+
// 添加安全属性
|
|
178
|
+
cleanHtml = cleanHtml.replace(/<a(\s+href=)/gi, '<a target="_blank" rel="noopener noreferrer"$1');
|
|
179
|
+
return cleanHtml;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 创建带有 HTML 清理功能的模板内容处理函数
|
|
183
|
+
*
|
|
184
|
+
* @param config - 配置对象
|
|
185
|
+
* @returns 清理函数
|
|
186
|
+
*/
|
|
187
|
+
function createSanitizer(config) {
|
|
188
|
+
const enabled = config.security?.sanitizeHtml !== false;
|
|
189
|
+
return {
|
|
190
|
+
/**
|
|
191
|
+
* 清理 HTML 内容
|
|
192
|
+
*/
|
|
193
|
+
sanitize: (html) => {
|
|
194
|
+
if (!enabled)
|
|
195
|
+
return html;
|
|
196
|
+
return sanitizeHtml(html);
|
|
197
|
+
},
|
|
198
|
+
/**
|
|
199
|
+
* 清理并返回纯文本
|
|
200
|
+
*/
|
|
201
|
+
sanitizeToText: (html) => {
|
|
202
|
+
if (!enabled)
|
|
203
|
+
return html;
|
|
204
|
+
return sanitizeToText(html);
|
|
205
|
+
},
|
|
206
|
+
/**
|
|
207
|
+
* 清理图片
|
|
208
|
+
*/
|
|
209
|
+
sanitizeImages: (html) => {
|
|
210
|
+
if (!enabled)
|
|
211
|
+
return html;
|
|
212
|
+
return sanitizeImageUrls(html);
|
|
213
|
+
},
|
|
214
|
+
/**
|
|
215
|
+
* 清理链接
|
|
216
|
+
*/
|
|
217
|
+
sanitizeLinks: (html) => {
|
|
218
|
+
if (!enabled)
|
|
219
|
+
return html;
|
|
220
|
+
return sanitizeLinks(html);
|
|
221
|
+
},
|
|
222
|
+
/**
|
|
223
|
+
* 检查是否启用清理
|
|
224
|
+
*/
|
|
225
|
+
isEnabled: () => enabled
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Config } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* 安全验证错误类
|
|
4
|
+
*/
|
|
5
|
+
export declare class SecurityError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function isInternalUrl(urlString: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* URL 白名单/黑名单验证
|
|
11
|
+
*
|
|
12
|
+
* @param urlString - 要检查的 URL
|
|
13
|
+
* @param whitelist - 白名单域名列表
|
|
14
|
+
* @param blacklist - 黑名单域名列表
|
|
15
|
+
* @returns true 表示允许访问
|
|
16
|
+
*/
|
|
17
|
+
export declare function isAllowedUrl(urlString: string, whitelist?: string[], blacklist?: string[]): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* 综合 URL 验证
|
|
20
|
+
* 检查协议、内网 IP、白名单/黑名单
|
|
21
|
+
*
|
|
22
|
+
* @param urlString - 要检查的 URL
|
|
23
|
+
* @param options - 验证选项
|
|
24
|
+
* @returns 验证结果对象
|
|
25
|
+
*/
|
|
26
|
+
export declare function validateUrl(urlString: string, options?: {
|
|
27
|
+
whitelist?: string[];
|
|
28
|
+
blacklist?: string[];
|
|
29
|
+
allowHttp?: boolean;
|
|
30
|
+
allowHttps?: boolean;
|
|
31
|
+
allowOtherProtocols?: boolean;
|
|
32
|
+
allowInternalAccess?: boolean;
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
}): {
|
|
35
|
+
valid: boolean;
|
|
36
|
+
error?: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* 验证并抛出异常的便捷函数
|
|
40
|
+
*
|
|
41
|
+
* @param urlString - 要检查的 URL
|
|
42
|
+
* @param options - 验证选项
|
|
43
|
+
* @throws SecurityError 当验证失败时
|
|
44
|
+
*/
|
|
45
|
+
export declare function validateUrlOrThrow(urlString: string, options?: {
|
|
46
|
+
whitelist?: string[];
|
|
47
|
+
blacklist?: string[];
|
|
48
|
+
allowHttp?: boolean;
|
|
49
|
+
allowHttps?: boolean;
|
|
50
|
+
allowOtherProtocols?: boolean;
|
|
51
|
+
allowInternalAccess?: boolean;
|
|
52
|
+
enabled?: boolean;
|
|
53
|
+
}): void;
|
|
54
|
+
/**
|
|
55
|
+
* 从配置中获取安全验证选项
|
|
56
|
+
*
|
|
57
|
+
* @param config - 插件配置对象
|
|
58
|
+
* @returns 安全验证选项
|
|
59
|
+
*/
|
|
60
|
+
export declare function getSecurityOptions(config: Config): {
|
|
61
|
+
whitelist: string[];
|
|
62
|
+
blacklist: string[];
|
|
63
|
+
allowHttp: boolean;
|
|
64
|
+
allowHttps: boolean;
|
|
65
|
+
allowInternalAccess: boolean;
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* 创建带有 URL 验证的 HTTP 函数包装器
|
|
70
|
+
*
|
|
71
|
+
* @param httpFunction - 原始的 HTTP 函数
|
|
72
|
+
* @param config - 配置对象(用于获取白名单/黑名单)
|
|
73
|
+
* @returns 包装后的 HTTP 函数
|
|
74
|
+
*/
|
|
75
|
+
export declare function createSafeHttpFunction(httpFunction: (url: string, ...args: any[]) => Promise<any>, config: Config): (url: string, ...args: any[]) => Promise<any>;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SecurityError = void 0;
|
|
4
|
+
exports.isInternalUrl = isInternalUrl;
|
|
5
|
+
exports.isAllowedUrl = isAllowedUrl;
|
|
6
|
+
exports.validateUrl = validateUrl;
|
|
7
|
+
exports.validateUrlOrThrow = validateUrlOrThrow;
|
|
8
|
+
exports.getSecurityOptions = getSecurityOptions;
|
|
9
|
+
exports.createSafeHttpFunction = createSafeHttpFunction;
|
|
10
|
+
/**
|
|
11
|
+
* 安全验证错误类
|
|
12
|
+
*/
|
|
13
|
+
class SecurityError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'SecurityError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
exports.SecurityError = SecurityError;
|
|
20
|
+
// 内网 IP 范围定义
|
|
21
|
+
const INTERNAL_IP_RANGES = [
|
|
22
|
+
// IPv4 回环地址
|
|
23
|
+
{ start: '127.0.0.0', end: '127.255.255.255' },
|
|
24
|
+
// IPv4 私有地址 - 10.0.0.0/8
|
|
25
|
+
{ start: '10.0.0.0', end: '10.255.255.255' },
|
|
26
|
+
// IPv4 私有地址 - 172.16.0.0/12
|
|
27
|
+
{ start: '172.16.0.0', end: '172.31.255.255' },
|
|
28
|
+
// IPv4 私有地址 - 192.168.0.0/16
|
|
29
|
+
{ start: '192.168.0.0', end: '192.168.255.255' },
|
|
30
|
+
// IPv4 链路本地地址 - 169.254.0.0/16 (包含 169.254.169.254 云元数据)
|
|
31
|
+
{ start: '169.254.0.0', end: '169.254.255.255' },
|
|
32
|
+
// IPv4 广播地址
|
|
33
|
+
{ start: '255.255.255.255', end: '255.255.255.255' },
|
|
34
|
+
// IPv4 0.0.0.0
|
|
35
|
+
{ start: '0.0.0.0', end: '0.0.0.0' },
|
|
36
|
+
// IPv6 回环地址
|
|
37
|
+
{ start: '::1', end: '::1' },
|
|
38
|
+
// IPv6 链路本地地址 (fe80::/10)
|
|
39
|
+
{ start: 'fe80::', end: 'febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff' },
|
|
40
|
+
// IPv6 唯一本地地址 (fc00::/7)
|
|
41
|
+
{ start: 'fc00::', end: 'fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' },
|
|
42
|
+
];
|
|
43
|
+
// 禁止的 Hostname 关键词
|
|
44
|
+
const FORBIDDEN_HOSTNAMES = [
|
|
45
|
+
'localhost',
|
|
46
|
+
'localhost.localdomain',
|
|
47
|
+
'metadata.google.internal', // GCP 元数据服务
|
|
48
|
+
'metadata.google', // GCP 元数据
|
|
49
|
+
'kubernetes.default.svc', // Kubernetes
|
|
50
|
+
'kubernetes.default', // Kubernetes
|
|
51
|
+
'etcd-client.kube-system', // Kubernetes etcd
|
|
52
|
+
'etcd.kube-system', // Kubernetes etcd
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* 将 IP 字符串转换为数字数组(支持 IPv4 和 IPv6)
|
|
56
|
+
*/
|
|
57
|
+
function ipToNumberArray(ip) {
|
|
58
|
+
if (ip.includes(':')) {
|
|
59
|
+
// IPv6
|
|
60
|
+
const parts = ip.split(':');
|
|
61
|
+
return parts.map(part => {
|
|
62
|
+
if (part === '')
|
|
63
|
+
return 0;
|
|
64
|
+
return parseInt(part, 16);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// IPv4
|
|
69
|
+
return ip.split('.').map(part => parseInt(part, 10));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 比较两个 IP 地址(支持 IPv4 和 IPv6)
|
|
74
|
+
* 返回负数表示 a < b,正数表示 a > b,0 表示相等
|
|
75
|
+
*/
|
|
76
|
+
function compareIps(a, b) {
|
|
77
|
+
const aArr = ipToNumberArray(a);
|
|
78
|
+
const bArr = ipToNumberArray(b);
|
|
79
|
+
const maxLen = Math.max(aArr.length, bArr.length);
|
|
80
|
+
for (let i = 0; i < maxLen; i++) {
|
|
81
|
+
const aVal = aArr[i] || 0;
|
|
82
|
+
const bVal = bArr[i] || 0;
|
|
83
|
+
if (aVal !== bVal) {
|
|
84
|
+
return aVal - bVal;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 检查 IP 是否在指定范围内
|
|
91
|
+
*/
|
|
92
|
+
function isIpInRange(ip, start, end) {
|
|
93
|
+
return compareIps(ip, start) >= 0 && compareIps(ip, end) <= 0;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 检测是否为内网 IP
|
|
97
|
+
*
|
|
98
|
+
* @param urlString - 要检查的 URL
|
|
99
|
+
* @returns true 表示是内网 IP
|
|
100
|
+
*/
|
|
101
|
+
/**
|
|
102
|
+
* 验证 IPv4 地址是否合法(每个八位组在 0-255 之间)
|
|
103
|
+
*/
|
|
104
|
+
function isValidIPv4(ip) {
|
|
105
|
+
const parts = ip.split('.');
|
|
106
|
+
if (parts.length !== 4)
|
|
107
|
+
return false;
|
|
108
|
+
return parts.every(part => {
|
|
109
|
+
const num = parseInt(part, 10);
|
|
110
|
+
return !isNaN(num) && num >= 0 && num <= 255 && String(num) === part;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 验证 IPv6 地址是否合法
|
|
115
|
+
*/
|
|
116
|
+
function isValidIPv6(ip) {
|
|
117
|
+
// 简单验证:检查是否为有效的 IPv6 格式
|
|
118
|
+
if (ip === '::1' || ip === '::')
|
|
119
|
+
return true;
|
|
120
|
+
const parts = ip.split(':');
|
|
121
|
+
// IPv6 应该最多有 8 个部分
|
|
122
|
+
if (parts.length > 8)
|
|
123
|
+
return false;
|
|
124
|
+
return parts.every(part => {
|
|
125
|
+
if (part === '')
|
|
126
|
+
return true;
|
|
127
|
+
// 每个部分应该是 0-4 位十六进制
|
|
128
|
+
return /^[0-9a-fA-F]{1,4}$/.test(part);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function isInternalUrl(urlString) {
|
|
132
|
+
if (!urlString)
|
|
133
|
+
return false;
|
|
134
|
+
try {
|
|
135
|
+
const url = new URL(urlString);
|
|
136
|
+
const hostname = url.hostname.toLowerCase();
|
|
137
|
+
// 检查禁止的 hostname 关键词
|
|
138
|
+
for (const forbidden of FORBIDDEN_HOSTNAMES) {
|
|
139
|
+
if (hostname === forbidden || hostname.endsWith('.' + forbidden)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// 检查是否为纯 IP 地址
|
|
144
|
+
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
145
|
+
const ipv6Pattern = /^([0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}$|^::1$|^::$/;
|
|
146
|
+
if (ipPattern.test(hostname)) {
|
|
147
|
+
// 验证 IPv4 是否合法
|
|
148
|
+
if (!isValidIPv4(hostname)) {
|
|
149
|
+
// 无效的 IP 地址,拒绝访问
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
// IPv4
|
|
153
|
+
for (const range of INTERNAL_IP_RANGES) {
|
|
154
|
+
if (range.start.includes('.')) { // IPv4 range
|
|
155
|
+
if (isIpInRange(hostname, range.start, range.end)) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (ipv6Pattern.test(hostname) || hostname.includes(':')) {
|
|
162
|
+
// 验证 IPv6 是否合法
|
|
163
|
+
if (!isValidIPv6(hostname)) {
|
|
164
|
+
// 无效的 IP 地址,拒绝访问
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
// IPv6
|
|
168
|
+
for (const range of INTERNAL_IP_RANGES) {
|
|
169
|
+
if (range.start.includes(':')) { // IPv6 range
|
|
170
|
+
if (isIpInRange(hostname, range.start, range.end)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* URL 白名单/黑名单验证
|
|
184
|
+
*
|
|
185
|
+
* @param urlString - 要检查的 URL
|
|
186
|
+
* @param whitelist - 白名单域名列表
|
|
187
|
+
* @param blacklist - 黑名单域名列表
|
|
188
|
+
* @returns true 表示允许访问
|
|
189
|
+
*/
|
|
190
|
+
function isAllowedUrl(urlString, whitelist, blacklist) {
|
|
191
|
+
if (!urlString)
|
|
192
|
+
return false;
|
|
193
|
+
try {
|
|
194
|
+
const url = new URL(urlString);
|
|
195
|
+
const hostname = url.hostname.toLowerCase();
|
|
196
|
+
// 先检查黑名单
|
|
197
|
+
if (blacklist && blacklist.length > 0) {
|
|
198
|
+
for (const blocked of blacklist) {
|
|
199
|
+
const blockedLower = blocked.toLowerCase();
|
|
200
|
+
if (hostname === blockedLower || hostname.endsWith('.' + blockedLower)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// 如果有白名单,检查是否在白名单中
|
|
206
|
+
if (whitelist && whitelist.length > 0) {
|
|
207
|
+
for (const allowed of whitelist) {
|
|
208
|
+
const allowedLower = allowed.toLowerCase();
|
|
209
|
+
if (hostname === allowedLower || hostname.endsWith('.' + allowedLower)) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// 不在白名单中,拒绝访问
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
// 没有白名单,默认允许(但还需要通过内网检查)
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* 综合 URL 验证
|
|
225
|
+
* 检查协议、内网 IP、白名单/黑名单
|
|
226
|
+
*
|
|
227
|
+
* @param urlString - 要检查的 URL
|
|
228
|
+
* @param options - 验证选项
|
|
229
|
+
* @returns 验证结果对象
|
|
230
|
+
*/
|
|
231
|
+
function validateUrl(urlString, options = {}) {
|
|
232
|
+
// 安全检查默认不启用
|
|
233
|
+
if (options.enabled !== true) {
|
|
234
|
+
return { valid: true };
|
|
235
|
+
}
|
|
236
|
+
if (!urlString) {
|
|
237
|
+
return { valid: false, error: 'URL 不能为空' };
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const url = new URL(urlString);
|
|
241
|
+
// 协议检查
|
|
242
|
+
const protocol = url.protocol.toLowerCase();
|
|
243
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
244
|
+
if (!options.allowOtherProtocols) {
|
|
245
|
+
return { valid: false, error: `不支持的协议: ${protocol}` };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// HTTP/HTTPS 显式禁用检查
|
|
249
|
+
if (protocol === 'http:' && options.allowHttp === false) {
|
|
250
|
+
return { valid: false, error: 'HTTP 协议已被禁用' };
|
|
251
|
+
}
|
|
252
|
+
if (protocol === 'https:' && options.allowHttps === false) {
|
|
253
|
+
return { valid: false, error: 'HTTPS 协议已被禁用' };
|
|
254
|
+
}
|
|
255
|
+
// 内网 IP 检查(除非明确允许)
|
|
256
|
+
if (options.allowInternalAccess !== true && isInternalUrl(urlString)) {
|
|
257
|
+
return { valid: false, error: '不允许访问内网 IP 地址' };
|
|
258
|
+
}
|
|
259
|
+
// 白名单/黑名单检查
|
|
260
|
+
if (!isAllowedUrl(urlString, options.whitelist, options.blacklist)) {
|
|
261
|
+
return { valid: false, error: 'URL 不在允许列表中或被列入黑名单' };
|
|
262
|
+
}
|
|
263
|
+
return { valid: true };
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
return { valid: false, error: `URL 解析失败: ${error.message}` };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 验证并抛出异常的便捷函数
|
|
271
|
+
*
|
|
272
|
+
* @param urlString - 要检查的 URL
|
|
273
|
+
* @param options - 验证选项
|
|
274
|
+
* @throws SecurityError 当验证失败时
|
|
275
|
+
*/
|
|
276
|
+
function validateUrlOrThrow(urlString, options = {}) {
|
|
277
|
+
const result = validateUrl(urlString, options);
|
|
278
|
+
if (!result.valid) {
|
|
279
|
+
throw new SecurityError(result.error);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 从配置中获取安全验证选项
|
|
284
|
+
*
|
|
285
|
+
* @param config - 插件配置对象
|
|
286
|
+
* @returns 安全验证选项
|
|
287
|
+
*/
|
|
288
|
+
function getSecurityOptions(config) {
|
|
289
|
+
return {
|
|
290
|
+
whitelist: config.security?.whitelist,
|
|
291
|
+
blacklist: config.security?.blacklist,
|
|
292
|
+
allowHttp: config.security?.allowHttp !== false,
|
|
293
|
+
allowHttps: config.security?.allowHttps !== false,
|
|
294
|
+
allowInternalAccess: config.security?.allowInternalAccess === true,
|
|
295
|
+
enabled: config.security?.enabled === true,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 创建带有 URL 验证的 HTTP 函数包装器
|
|
300
|
+
*
|
|
301
|
+
* @param httpFunction - 原始的 HTTP 函数
|
|
302
|
+
* @param config - 配置对象(用于获取白名单/黑名单)
|
|
303
|
+
* @returns 包装后的 HTTP 函数
|
|
304
|
+
*/
|
|
305
|
+
function createSafeHttpFunction(httpFunction, config) {
|
|
306
|
+
const securityOptions = getSecurityOptions(config);
|
|
307
|
+
return async (url, ...args) => {
|
|
308
|
+
// 验证 URL
|
|
309
|
+
validateUrlOrThrow(url, securityOptions);
|
|
310
|
+
return httpFunction(url, ...args);
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -12,9 +12,7 @@ exports.logDetails = logDetails;
|
|
|
12
12
|
exports.logError = logError;
|
|
13
13
|
exports.createTimer = createTimer;
|
|
14
14
|
exports.logPerformance = logPerformance;
|
|
15
|
-
const
|
|
16
|
-
const types_1 = require("../types");
|
|
17
|
-
const logger = new koishi_1.Logger('rss-owl');
|
|
15
|
+
const logger_1 = require("./logger");
|
|
18
16
|
/**
|
|
19
17
|
* 日志级别枚举
|
|
20
18
|
*/
|
|
@@ -73,11 +71,7 @@ class StructuredLogger {
|
|
|
73
71
|
* 判断是否应该记录日志
|
|
74
72
|
*/
|
|
75
73
|
shouldLog(level) {
|
|
76
|
-
|
|
77
|
-
if (typeLevel < 1)
|
|
78
|
-
return false;
|
|
79
|
-
const configLevel = types_1.debugLevel.findIndex(i => i === this.config.debug);
|
|
80
|
-
return typeLevel <= configLevel;
|
|
74
|
+
return (0, logger_1.shouldLog)(this.config, level);
|
|
81
75
|
}
|
|
82
76
|
/**
|
|
83
77
|
* 格式化日志条目
|
|
@@ -127,18 +121,7 @@ class StructuredLogger {
|
|
|
127
121
|
return;
|
|
128
122
|
}
|
|
129
123
|
const formattedMessage = this.formatEntry(entry);
|
|
130
|
-
|
|
131
|
-
switch (entry.level) {
|
|
132
|
-
case LogLevel.ERROR:
|
|
133
|
-
logger.error(formattedMessage);
|
|
134
|
-
break;
|
|
135
|
-
case LogLevel.INFO:
|
|
136
|
-
case LogLevel.DETAILS:
|
|
137
|
-
logger.info(formattedMessage);
|
|
138
|
-
break;
|
|
139
|
-
case LogLevel.DISABLE:
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
124
|
+
(0, logger_1.debug)(this.config, formattedMessage, '', entry.level);
|
|
142
125
|
}
|
|
143
126
|
/**
|
|
144
127
|
* 记录普通信息
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anyul/koishi-plugin-rss",
|
|
3
3
|
"description": "Koishi RSS订阅器,支持多种RSS源、图片渲染、AI摘要等高级功能",
|
|
4
|
-
"version": "5.2.
|
|
4
|
+
"version": "5.2.3",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"author": "Anyuluo <anyul@email.com>",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"axios": "^1.13.2",
|
|
37
37
|
"cheerio": "^1.1.2",
|
|
38
38
|
"https-proxy-agent": "^7.0.6",
|
|
39
|
+
"isomorphic-dompurify": "^2.14.0",
|
|
39
40
|
"koishi": "^4.18.10",
|
|
40
41
|
"koishi-plugin-puppeteer": "^3.9.0",
|
|
41
42
|
"marked": "^4.3.0",
|