vk-ssl-auto-deploy 0.0.1 → 0.0.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/README.md +46 -8
- package/app.js +1 -1
- package/config.json +3 -3
- package/package.json +2 -1
- package/public/stylesheets/style.css +8 -8
- package/utils/certValidator.js +394 -0
- package/utils/ipValidator.js +214 -0
- package/views/error.ejs +3 -3
- package/views/index.ejs +10 -10
package/README.md
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
### 1. NPM 全局安装(推荐)
|
|
2
2
|
|
|
3
|
+
安装node环境
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
|
|
7
|
+
sudo yum install -y nodejs
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
验证npm
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
node -v
|
|
14
|
+
npm -v
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
安装pm2
|
|
18
|
+
|
|
3
19
|
```bash
|
|
4
|
-
npm install -g
|
|
20
|
+
npm install -g pm2
|
|
5
21
|
```
|
|
6
22
|
|
|
23
|
+
安装自动部署工具
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g vk-ssl-auto-deploy@latest
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
查看安装目录
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm root -g
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
在返回的结果中拼接 `/vk-ssl-auto-deploy`
|
|
36
|
+
|
|
37
|
+
如 `/usr/lib/node_modules/vk-ssl-auto-deploy`
|
|
38
|
+
|
|
39
|
+
### 2. 启动
|
|
40
|
+
|
|
7
41
|
安装后可以直接使用命令启动:
|
|
8
42
|
|
|
9
43
|
```bash
|
|
@@ -12,11 +46,15 @@ vk-ssl
|
|
|
12
46
|
|
|
13
47
|
# 或使用 PM2 管理(需要先安装 PM2)
|
|
14
48
|
npm install -g pm2
|
|
15
|
-
cd
|
|
49
|
+
cd <安装目录>,如 cd /usr/lib/node_modules/vk-ssl-auto-deploy
|
|
16
50
|
npm run start
|
|
17
51
|
```
|
|
18
52
|
|
|
19
|
-
|
|
53
|
+
```js
|
|
54
|
+
cd /usr/lib/node_modules/vk-ssl-auto-deploy/cert
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 3. 本地开发安装
|
|
20
58
|
|
|
21
59
|
```bash
|
|
22
60
|
# 克隆项目
|
|
@@ -30,7 +68,7 @@ npm install
|
|
|
30
68
|
npm dev
|
|
31
69
|
```
|
|
32
70
|
|
|
33
|
-
###
|
|
71
|
+
### 4. PM2 进程管理(生产环境推荐)
|
|
34
72
|
|
|
35
73
|
```bash
|
|
36
74
|
# 启动服务
|
|
@@ -52,7 +90,7 @@ npm run delete
|
|
|
52
90
|
npm run list
|
|
53
91
|
```
|
|
54
92
|
|
|
55
|
-
###
|
|
93
|
+
### 5. 证书自动部署API
|
|
56
94
|
|
|
57
95
|
#### 接口说明
|
|
58
96
|
|
|
@@ -137,7 +175,7 @@ fetch('http://localhost:3000/api/deploy-cert', {
|
|
|
137
175
|
.then(data => console.log(data));
|
|
138
176
|
```
|
|
139
177
|
|
|
140
|
-
###
|
|
178
|
+
### 6. 配置说明
|
|
141
179
|
|
|
142
180
|
编辑 `config.json` 文件可以修改服务端口和路由前缀:
|
|
143
181
|
|
|
@@ -153,11 +191,11 @@ fetch('http://localhost:3000/api/deploy-cert', {
|
|
|
153
191
|
- `routerName`: API 路由前缀(默认 "api",即访问路径为 `/api/...`)
|
|
154
192
|
- `ipWhitelist`: IP白名单,默认为空,表示不限制IP,如果需要限制IP,可以填写IP地址,多个IP地址用逗号分隔
|
|
155
193
|
|
|
156
|
-
###
|
|
194
|
+
### 7. License
|
|
157
195
|
|
|
158
196
|
MIT
|
|
159
197
|
|
|
160
|
-
###
|
|
198
|
+
### 8. 发布到 NPM(维护者使用)
|
|
161
199
|
|
|
162
200
|
#### 发布前准备
|
|
163
201
|
|
package/app.js
CHANGED
package/config.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vk-ssl-auto-deploy",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "SSL证书自动部署工具 - 提供HTTP API接口,支持证书文件自动上传和部署",
|
|
5
5
|
"main": "app.js",
|
|
6
6
|
"bin": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"bin/",
|
|
39
39
|
"routes/",
|
|
40
|
+
"utils/",
|
|
40
41
|
"views/",
|
|
41
42
|
"public/",
|
|
42
43
|
"app.js",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
body {
|
|
2
|
-
padding: 50px;
|
|
3
|
-
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
a {
|
|
7
|
-
color: #00B7FF;
|
|
8
|
-
}
|
|
1
|
+
body {
|
|
2
|
+
padding: 50px;
|
|
3
|
+
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
a {
|
|
7
|
+
color: #00B7FF;
|
|
8
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 证书文件验证工具模块
|
|
3
|
+
* 提供证书文件的安全验证功能,包括扩展名和文件内容的验证
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
// 允许的证书文件扩展名白名单
|
|
9
|
+
const ALLOWED_EXTENSIONS = ['.pem', '.crt', '.key', '.der', '.jks', '.p7b', '.pfx', '.txt'];
|
|
10
|
+
|
|
11
|
+
// 单个证书文件的最大大小限制(1MB)
|
|
12
|
+
const MAX_FILE_SIZE = 1 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 检查文件扩展名是否在白名单中
|
|
16
|
+
* @param {string} filename - 文件名
|
|
17
|
+
* @returns {boolean} 是否允许
|
|
18
|
+
*/
|
|
19
|
+
function isAllowedExtension(filename) {
|
|
20
|
+
const ext = path.extname(filename).toLowerCase();
|
|
21
|
+
return ALLOWED_EXTENSIONS.includes(ext);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 验证PEM格式文件内容
|
|
26
|
+
* PEM文件是Base64编码的文本格式,必须包含BEGIN和END标记
|
|
27
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
28
|
+
* @returns {boolean} 是否为有效的PEM格式
|
|
29
|
+
*/
|
|
30
|
+
function validatePEM(buffer) {
|
|
31
|
+
try {
|
|
32
|
+
const content = buffer.toString('utf8');
|
|
33
|
+
|
|
34
|
+
// 检查是否包含PEM格式的标记
|
|
35
|
+
const pemPattern = /-----BEGIN [A-Z0-9 ]+-----[\s\S]+-----END [A-Z0-9 ]+-----/;
|
|
36
|
+
if (!pemPattern.test(content)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 检查常见的PEM类型标识符
|
|
41
|
+
const validPEMTypes = [
|
|
42
|
+
'CERTIFICATE',
|
|
43
|
+
'PRIVATE KEY',
|
|
44
|
+
'RSA PRIVATE KEY',
|
|
45
|
+
'EC PRIVATE KEY',
|
|
46
|
+
'DSA PRIVATE KEY',
|
|
47
|
+
'ENCRYPTED PRIVATE KEY',
|
|
48
|
+
'CERTIFICATE REQUEST',
|
|
49
|
+
'X509 CRL',
|
|
50
|
+
'PUBLIC KEY',
|
|
51
|
+
'RSA PUBLIC KEY',
|
|
52
|
+
'DH PARAMETERS',
|
|
53
|
+
'EC PARAMETERS'
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const hasValidType = validPEMTypes.some(type =>
|
|
57
|
+
content.includes(`-----BEGIN ${type}-----`) &&
|
|
58
|
+
content.includes(`-----END ${type}-----`)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (!hasValidType) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 提取并验证Base64内容的基本格式
|
|
66
|
+
const base64Content = content
|
|
67
|
+
.replace(/-----BEGIN [A-Z0-9 ]+-----/g, '')
|
|
68
|
+
.replace(/-----END [A-Z0-9 ]+-----/g, '')
|
|
69
|
+
.replace(/\s/g, '');
|
|
70
|
+
|
|
71
|
+
// Base64字符集验证
|
|
72
|
+
const base64Pattern = /^[A-Za-z0-9+/=]+$/;
|
|
73
|
+
return base64Pattern.test(base64Content);
|
|
74
|
+
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 验证DER格式文件内容
|
|
82
|
+
* DER是二进制格式,遵循ASN.1编码规则
|
|
83
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
84
|
+
* @returns {boolean} 是否为有效的DER格式
|
|
85
|
+
*/
|
|
86
|
+
function validateDER(buffer) {
|
|
87
|
+
try {
|
|
88
|
+
if (buffer.length < 4) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// DER格式的ASN.1结构通常以30开头(SEQUENCE标记)
|
|
93
|
+
// 常见的证书和私钥DER文件开头:30 82, 30 83, 30 84等
|
|
94
|
+
const firstByte = buffer[0];
|
|
95
|
+
|
|
96
|
+
// 检查是否以SEQUENCE标签开始(0x30)
|
|
97
|
+
if (firstByte !== 0x30) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 检查长度编码的合法性
|
|
102
|
+
const secondByte = buffer[1];
|
|
103
|
+
|
|
104
|
+
// 如果第二个字节最高位为1,表示长格式长度编码
|
|
105
|
+
if (secondByte & 0x80) {
|
|
106
|
+
const lengthOfLength = secondByte & 0x7F;
|
|
107
|
+
// 长度字节数应该在合理范围内(通常1-4字节)
|
|
108
|
+
if (lengthOfLength < 1 || lengthOfLength > 4 || buffer.length < 2 + lengthOfLength) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return true;
|
|
114
|
+
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 验证JKS (Java KeyStore) 格式文件内容
|
|
122
|
+
* JKS文件有固定的魔数:FE ED FE ED (标准JKS) 或 CE CE CE CE (JCEKS)
|
|
123
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
124
|
+
* @returns {boolean} 是否为有效的JKS格式
|
|
125
|
+
*/
|
|
126
|
+
function validateJKS(buffer) {
|
|
127
|
+
try {
|
|
128
|
+
// JKS 文件最小长度检查(通常至少几百字节)
|
|
129
|
+
if (buffer.length < 32) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 检查标准 JKS 魔数:0xFEEDFEED
|
|
134
|
+
const isStandardJKS = buffer[0] === 0xFE &&
|
|
135
|
+
buffer[1] === 0xED &&
|
|
136
|
+
buffer[2] === 0xFE &&
|
|
137
|
+
buffer[3] === 0xED;
|
|
138
|
+
|
|
139
|
+
// 检查 JCEKS (Java Cryptography Extension KeyStore) 魔数:0xCECECECE
|
|
140
|
+
const isJCEKS = buffer[0] === 0xCE &&
|
|
141
|
+
buffer[1] === 0xCE &&
|
|
142
|
+
buffer[2] === 0xCE &&
|
|
143
|
+
buffer[3] === 0xCE;
|
|
144
|
+
|
|
145
|
+
// 检查是否为 PKCS12 格式(某些 .jks 文件实际上是 PKCS12)
|
|
146
|
+
const isPKCS12 = buffer[0] === 0x30 &&
|
|
147
|
+
(buffer[1] === 0x82 || buffer[1] === 0x80 ||
|
|
148
|
+
buffer[1] === 0x83 || buffer[1] === 0x84);
|
|
149
|
+
|
|
150
|
+
return isStandardJKS || isJCEKS || isPKCS12;
|
|
151
|
+
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 验证PKCS#7 (.p7b) 格式文件内容
|
|
159
|
+
* PKCS#7可以是DER或PEM格式,格式变体众多,采用宽松验证策略
|
|
160
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
161
|
+
* @returns {boolean} 是否为有效的PKCS#7格式
|
|
162
|
+
*/
|
|
163
|
+
function validateP7B(buffer) {
|
|
164
|
+
try {
|
|
165
|
+
// 最小文件大小检查
|
|
166
|
+
if (buffer.length < 10) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 1. 尝试检测 PEM 格式(文本格式)
|
|
171
|
+
try {
|
|
172
|
+
const content = buffer.toString('utf8');
|
|
173
|
+
|
|
174
|
+
// 检查 PEM 标记(各种变体)
|
|
175
|
+
if (content.includes('-----BEGIN') || content.includes('-----END')) {
|
|
176
|
+
// 有 PEM 标记就认为是文本格式的 P7B
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
} catch (encodeError) {
|
|
180
|
+
// 转字符串失败,继续下面的二进制检查
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2. 排除明显不是证书的文件类型
|
|
184
|
+
// 检查是否为常见图片格式的魔数
|
|
185
|
+
const firstByte = buffer[0];
|
|
186
|
+
const secondByte = buffer[1];
|
|
187
|
+
|
|
188
|
+
// JPEG: FF D8 FF
|
|
189
|
+
if (firstByte === 0xFF && secondByte === 0xD8 && buffer[2] === 0xFF) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// PNG: 89 50 4E 47
|
|
194
|
+
if (firstByte === 0x89 && secondByte === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// GIF: 47 49 46 38
|
|
199
|
+
if (firstByte === 0x47 && secondByte === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// BMP: 42 4D
|
|
204
|
+
if (firstByte === 0x42 && secondByte === 0x4D) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// PDF: 25 50 44 46
|
|
209
|
+
if (firstByte === 0x25 && secondByte === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ZIP: 50 4B
|
|
214
|
+
if (firstByte === 0x50 && secondByte === 0x4B) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. PKCS#7 格式变体很多,只要不是明显的非证书文件,就认为可能是有效的 P7B
|
|
219
|
+
// 这是一个宽松但实用的策略
|
|
220
|
+
return true;
|
|
221
|
+
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 验证PKCS#12 (.pfx) 格式文件内容
|
|
229
|
+
* PFX文件是二进制格式,通常以30 82或30 80开头
|
|
230
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
231
|
+
* @returns {boolean} 是否为有效的PKCS#12格式
|
|
232
|
+
*/
|
|
233
|
+
function validatePFX(buffer) {
|
|
234
|
+
try {
|
|
235
|
+
if (buffer.length < 4) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// PKCS#12文件通常以ASN.1 SEQUENCE开头
|
|
240
|
+
// 魔数:30 82 或 30 80 或 30 83 等
|
|
241
|
+
return buffer[0] === 0x30 &&
|
|
242
|
+
(buffer[1] === 0x82 || buffer[1] === 0x80 || buffer[1] === 0x83 || buffer[1] === 0x84);
|
|
243
|
+
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 验证TXT格式文件(通常是说明文件)
|
|
251
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
252
|
+
* @returns {boolean} 是否为有效的TXT文件
|
|
253
|
+
*/
|
|
254
|
+
function validateTXT(buffer) {
|
|
255
|
+
try {
|
|
256
|
+
// TXT 文件最小大小检查
|
|
257
|
+
if (buffer.length === 0) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 尝试转换为 UTF-8 文本
|
|
262
|
+
const content = buffer.toString('utf8');
|
|
263
|
+
|
|
264
|
+
// 检查是否包含大量不可打印字符(可能是二进制文件伪装)
|
|
265
|
+
// 允许的字符:可打印ASCII字符、中文、换行符等
|
|
266
|
+
let invalidCharCount = 0;
|
|
267
|
+
for (let i = 0; i < Math.min(content.length, 1000); i++) {
|
|
268
|
+
const code = content.charCodeAt(i);
|
|
269
|
+
// 允许:换行、制表符、可打印ASCII、中文等Unicode字符
|
|
270
|
+
if (code < 32 && code !== 10 && code !== 13 && code !== 9) {
|
|
271
|
+
invalidCharCount++;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 如果不可打印字符超过10%,认为是二进制文件
|
|
276
|
+
const sampleSize = Math.min(content.length, 1000);
|
|
277
|
+
return (invalidCharCount / sampleSize) < 0.1;
|
|
278
|
+
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 验证证书文件的内容合法性
|
|
286
|
+
* 根据文件扩展名调用相应的验证函数
|
|
287
|
+
* @param {string} filename - 文件名
|
|
288
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
289
|
+
* @returns {boolean} 文件内容是否合法
|
|
290
|
+
*/
|
|
291
|
+
function validateFileContent(filename, buffer) {
|
|
292
|
+
const ext = path.extname(filename).toLowerCase();
|
|
293
|
+
|
|
294
|
+
switch (ext) {
|
|
295
|
+
case '.pem':
|
|
296
|
+
case '.crt':
|
|
297
|
+
case '.key':
|
|
298
|
+
return validatePEM(buffer);
|
|
299
|
+
|
|
300
|
+
case '.der':
|
|
301
|
+
return validateDER(buffer);
|
|
302
|
+
|
|
303
|
+
case '.jks':
|
|
304
|
+
return validateJKS(buffer);
|
|
305
|
+
|
|
306
|
+
case '.p7b':
|
|
307
|
+
return validateP7B(buffer);
|
|
308
|
+
|
|
309
|
+
case '.pfx':
|
|
310
|
+
return validatePFX(buffer);
|
|
311
|
+
|
|
312
|
+
case '.txt':
|
|
313
|
+
return validateTXT(buffer);
|
|
314
|
+
|
|
315
|
+
default:
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 验证文件大小是否在允许范围内
|
|
322
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
323
|
+
* @returns {boolean} 文件大小是否合法
|
|
324
|
+
*/
|
|
325
|
+
function validateFileSize(buffer) {
|
|
326
|
+
return buffer.length > 0 && buffer.length <= MAX_FILE_SIZE;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 验证文件路径,防止路径遍历攻击(Zip Slip)
|
|
331
|
+
* @param {string} entryName - zip条目名称
|
|
332
|
+
* @returns {boolean} 路径是否安全
|
|
333
|
+
*/
|
|
334
|
+
function validateFilePath(entryName) {
|
|
335
|
+
// 禁止包含父目录引用
|
|
336
|
+
if (entryName.includes('..')) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 禁止绝对路径
|
|
341
|
+
if (path.isAbsolute(entryName)) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 禁止以斜杠或反斜杠开头
|
|
346
|
+
if (entryName.startsWith('/') || entryName.startsWith('\\')) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 完整验证证书文件
|
|
355
|
+
* 包括:扩展名、文件大小、路径安全性、文件内容
|
|
356
|
+
* @param {string} filename - 文件名
|
|
357
|
+
* @param {Buffer} buffer - 文件内容Buffer
|
|
358
|
+
* @param {string} entryName - zip条目名称
|
|
359
|
+
* @returns {Object} 验证结果 { valid: boolean, reason: string }
|
|
360
|
+
*/
|
|
361
|
+
function validateCertFile(filename, buffer, entryName) {
|
|
362
|
+
// 1. 验证路径安全性
|
|
363
|
+
if (!validateFilePath(entryName)) {
|
|
364
|
+
return { valid: false, reason: '文件路径不安全,可能存在路径遍历攻击' };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 2. 验证扩展名
|
|
368
|
+
if (!isAllowedExtension(filename)) {
|
|
369
|
+
return { valid: false, reason: '文件扩展名不在白名单中' };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 3. 验证文件大小
|
|
373
|
+
if (!validateFileSize(buffer)) {
|
|
374
|
+
return { valid: false, reason: '文件大小不合法(空文件或超过1MB)' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 4. 验证文件内容
|
|
378
|
+
if (!validateFileContent(filename, buffer)) {
|
|
379
|
+
return { valid: false, reason: '文件内容格式不正确,可能是伪装的证书文件' };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { valid: true, reason: '验证通过' };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = {
|
|
386
|
+
ALLOWED_EXTENSIONS,
|
|
387
|
+
MAX_FILE_SIZE,
|
|
388
|
+
validateCertFile,
|
|
389
|
+
validateFileContent,
|
|
390
|
+
validateFileSize,
|
|
391
|
+
validateFilePath,
|
|
392
|
+
isAllowedExtension
|
|
393
|
+
};
|
|
394
|
+
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP白名单验证工具模块
|
|
3
|
+
* 提供IP地址和CIDR格式的白名单验证功能
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 将IP地址转换为32位整数
|
|
8
|
+
* @param {string} ip - IP地址字符串,如 "192.168.1.1"
|
|
9
|
+
* @returns {number} IP地址对应的32位整数
|
|
10
|
+
*/
|
|
11
|
+
function ipToInt(ip) {
|
|
12
|
+
const parts = ip.split('.').map(Number);
|
|
13
|
+
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 验证IP地址格式是否合法
|
|
18
|
+
* @param {string} ip - IP地址字符串
|
|
19
|
+
* @returns {boolean} IP地址是否合法
|
|
20
|
+
*/
|
|
21
|
+
function isValidIP(ip) {
|
|
22
|
+
const ipPattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
23
|
+
const match = ip.match(ipPattern);
|
|
24
|
+
|
|
25
|
+
if (!match) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 检查每个部分是否在0-255范围内
|
|
30
|
+
for (let i = 1; i <= 4; i++) {
|
|
31
|
+
const num = parseInt(match[i], 10);
|
|
32
|
+
if (num < 0 || num > 255) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 验证CIDR格式是否合法
|
|
42
|
+
* @param {string} cidr - CIDR格式字符串,如 "192.168.1.0/24"
|
|
43
|
+
* @returns {boolean} CIDR格式是否合法
|
|
44
|
+
*/
|
|
45
|
+
function isValidCIDR(cidr) {
|
|
46
|
+
const parts = cidr.split('/');
|
|
47
|
+
|
|
48
|
+
if (parts.length !== 2) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ip = parts[0];
|
|
53
|
+
const prefix = parseInt(parts[1], 10);
|
|
54
|
+
|
|
55
|
+
// 验证IP部分
|
|
56
|
+
if (!isValidIP(ip)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 验证前缀长度(0-32)
|
|
61
|
+
if (isNaN(prefix) || prefix < 0 || prefix > 32) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 检查IP是否在CIDR范围内
|
|
70
|
+
* @param {string} ip - 要检查的IP地址
|
|
71
|
+
* @param {string} cidr - CIDR格式,如 "192.168.1.0/24"
|
|
72
|
+
* @returns {boolean} IP是否在CIDR范围内
|
|
73
|
+
*/
|
|
74
|
+
function isIPInCIDR(ip, cidr) {
|
|
75
|
+
if (!isValidIP(ip) || !isValidCIDR(cidr)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const [networkIP, prefixStr] = cidr.split('/');
|
|
80
|
+
const prefix = parseInt(prefixStr, 10);
|
|
81
|
+
|
|
82
|
+
// 特殊处理:0.0.0.0/0 表示所有IP
|
|
83
|
+
if (prefix === 0) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ipInt = ipToInt(ip);
|
|
88
|
+
const networkInt = ipToInt(networkIP);
|
|
89
|
+
|
|
90
|
+
// 生成子网掩码
|
|
91
|
+
const mask = (0xFFFFFFFF << (32 - prefix)) >>> 0;
|
|
92
|
+
|
|
93
|
+
// 比较网络地址部分
|
|
94
|
+
return (ipInt & mask) === (networkInt & mask);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 检查IP是否在白名单中
|
|
99
|
+
* @param {string} ip - 要检查的IP地址
|
|
100
|
+
* @param {Array<string>} whitelist - IP白名单数组,支持单个IP或CIDR格式
|
|
101
|
+
* @returns {boolean} IP是否在白名单中
|
|
102
|
+
*/
|
|
103
|
+
function isIPInWhitelist(ip, whitelist) {
|
|
104
|
+
// 如果白名单为空或未定义,允许所有IP
|
|
105
|
+
if (!whitelist || !Array.isArray(whitelist) || whitelist.length === 0) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 验证要检查的IP格式
|
|
110
|
+
if (!isValidIP(ip)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 遍历白名单
|
|
115
|
+
for (const entry of whitelist) {
|
|
116
|
+
const trimmedEntry = entry.trim();
|
|
117
|
+
|
|
118
|
+
// 检查是否为CIDR格式
|
|
119
|
+
if (trimmedEntry.includes('/')) {
|
|
120
|
+
if (isIPInCIDR(ip, trimmedEntry)) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
// 单个IP地址精确匹配
|
|
125
|
+
if (ip === trimmedEntry) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 从Express请求对象中获取客户端真实IP地址
|
|
136
|
+
* 支持代理服务器场景(如nginx、cloudflare等)
|
|
137
|
+
* @param {Object} req - Express请求对象
|
|
138
|
+
* @returns {string} 客户端IP地址
|
|
139
|
+
*/
|
|
140
|
+
function getClientIP(req) {
|
|
141
|
+
// 1. 尝试从X-Forwarded-For头获取(常见于反向代理)
|
|
142
|
+
const forwardedFor = req.headers['x-forwarded-for'];
|
|
143
|
+
if (forwardedFor) {
|
|
144
|
+
// X-Forwarded-For可能包含多个IP,取第一个(客户端真实IP)
|
|
145
|
+
const ips = forwardedFor.split(',').map(ip => ip.trim());
|
|
146
|
+
if (ips.length > 0 && isValidIP(ips[0])) {
|
|
147
|
+
return ips[0];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. 尝试从X-Real-IP头获取(nginx常用)
|
|
152
|
+
const realIP = req.headers['x-real-ip'];
|
|
153
|
+
if (realIP && isValidIP(realIP)) {
|
|
154
|
+
return realIP;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 3. 尝试从CF-Connecting-IP获取(Cloudflare)
|
|
158
|
+
const cfIP = req.headers['cf-connecting-ip'];
|
|
159
|
+
if (cfIP && isValidIP(cfIP)) {
|
|
160
|
+
return cfIP;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 4. 从socket连接获取
|
|
164
|
+
let ip = req.connection?.remoteAddress ||
|
|
165
|
+
req.socket?.remoteAddress ||
|
|
166
|
+
req.connection?.socket?.remoteAddress ||
|
|
167
|
+
req.ip;
|
|
168
|
+
|
|
169
|
+
// 处理IPv6格式的IPv4地址(如 ::ffff:192.168.1.1)
|
|
170
|
+
if (ip && ip.startsWith('::ffff:')) {
|
|
171
|
+
ip = ip.substring(7);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 处理IPv6本地地址
|
|
175
|
+
if (ip === '::1') {
|
|
176
|
+
ip = '127.0.0.1';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return ip || '0.0.0.0';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Express中间件:验证IP白名单
|
|
184
|
+
* @param {Array<string>} whitelist - IP白名单数组
|
|
185
|
+
* @returns {Function} Express中间件函数
|
|
186
|
+
*/
|
|
187
|
+
function ipWhitelistMiddleware(whitelist) {
|
|
188
|
+
return (req, res, next) => {
|
|
189
|
+
const clientIP = getClientIP(req);
|
|
190
|
+
|
|
191
|
+
if (isIPInWhitelist(clientIP, whitelist)) {
|
|
192
|
+
// IP在白名单中,允许访问
|
|
193
|
+
next();
|
|
194
|
+
} else {
|
|
195
|
+
// IP不在白名单中,拒绝访问
|
|
196
|
+
console.warn(`[IP白名单拦截] IP: ${clientIP} 尝试访问但不在白名单中`);
|
|
197
|
+
res.status(403).json({
|
|
198
|
+
code: 4030,
|
|
199
|
+
msg: 'IP地址不在白名单中,访问被拒绝',
|
|
200
|
+
ip: clientIP
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
isValidIP,
|
|
208
|
+
isValidCIDR,
|
|
209
|
+
isIPInCIDR,
|
|
210
|
+
isIPInWhitelist,
|
|
211
|
+
getClientIP,
|
|
212
|
+
ipWhitelistMiddleware
|
|
213
|
+
};
|
|
214
|
+
|
package/views/error.ejs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<h1><%= message %></h1>
|
|
2
|
-
<h2><%= error.status %></h2>
|
|
3
|
-
<pre><%= error.stack %></pre>
|
|
1
|
+
<h1><%= message %></h1>
|
|
2
|
+
<h2><%= error.status %></h2>
|
|
3
|
+
<pre><%= error.stack %></pre>
|
package/views/index.ejs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
3
|
-
<head>
|
|
4
|
-
<title><%= title %></title>
|
|
5
|
-
<link rel='stylesheet' href='/stylesheets/style.css' />
|
|
6
|
-
</head>
|
|
7
|
-
<body>
|
|
8
|
-
<h1><%= title %></h1>
|
|
9
|
-
</body>
|
|
10
|
-
</html>
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= title %></title>
|
|
5
|
+
<link rel='stylesheet' href='/stylesheets/style.css' />
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<h1><%= title %></h1>
|
|
9
|
+
</body>
|
|
10
|
+
</html>
|