🔒 增强安全防护:防止 SSRF 攻击和 Token 泄露

后端安全增强:
- 新增 SSRF 防护机制,验证 SFTP 目标地址
- 添加 isPrivateIp() 函数,检测并阻止连接内网地址
- 添加 validateSftpDestination() 函数,验证主机名和端口
- 支持 DNS 解析和 IP 地址验证
- 添加 SFTP 连接超时配置(默认8秒)
- 移除 URL 参数中的 token 认证,只接受 Header 或 HttpOnly Cookie

前端安全改进:
- 移除下载链接中的 token 参数
- 改为依赖同域 Cookie 进行身份验证
- 避免 token 在 URL 中暴露,防止日志泄露

环境变量配置:
- ALLOW_PRIVATE_SFTP=true 可允许连接内网(测试环境)
- SFTP_CONNECT_TIMEOUT 可配置连接超时时间

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 20:07:34 +08:00
parent 19a6c70d42
commit 4e4ef0405b
3 changed files with 73 additions and 13 deletions

View File

@@ -51,8 +51,8 @@ function generateToken(user) {
// 验证Token中间件
function authMiddleware(req, res, next) {
// 从请求头、cookie或URL参数中获取token
const token = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token || req.query?.token;
// 从请求头或HttpOnly Cookie获取token不再接受URL参数以避免泄露
const token = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token;
if (!token) {
return res.status(401).json({

View File

@@ -14,6 +14,8 @@ const fs = require('fs');
const { body, validationResult } = require('express-validator');
const archiver = require('archiver');
const { exec, execSync } = require('child_process');
const net = require('net');
const dns = require('dns').promises;
const util = require('util');
const execAsync = util.promisify(exec);
@@ -626,14 +628,70 @@ function cleanupOldTempFiles() {
console.error('[清理] 清理临时文件目录失败:', error.message);
}
}
function isPrivateIp(ip) {
if (!net.isIP(ip)) return false;
return ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('172.23.') ||
ip.startsWith('172.24.') ||
ip.startsWith('172.25.') ||
ip.startsWith('172.26.') ||
ip.startsWith('172.27.') ||
ip.startsWith('172.28.') ||
ip.startsWith('172.29.') ||
ip.startsWith('172.30.') ||
ip.startsWith('172.31.') ||
ip.startsWith('127.') ||
ip === '0.0.0.0' ||
ip === '::1' ||
ip.startsWith('fe80:') ||
ip.startsWith('fd');
}
async function validateSftpDestination(host, port) {
if (!host || typeof host !== 'string' || host.length > 255) {
throw new Error('无效的SFTP主机');
}
if (host.includes('://') || host.includes('/')) {
throw new Error('SFTP主机不能包含协议或路径');
}
const portNum = parseInt(port, 10) || 22;
if (portNum < 1 || portNum > 65535) {
throw new Error('SFTP端口范围应为1-65535');
}
let resolvedIp;
try {
const lookup = await dns.lookup(host);
resolvedIp = lookup.address;
} catch (err) {
throw new Error('无法解析SFTP主机请检查域名或IP');
}
if (isPrivateIp(resolvedIp) && process.env.ALLOW_PRIVATE_SFTP !== 'true') {
throw new Error('出于安全考虑,不允许连接内网地址');
}
return { host, port: portNum, resolvedIp };
}
// SFTP连接
async function connectToSFTP(config) {
const sftp = new SftpClient();
await sftp.connect({
host: config.ftp_host,
port: config.ftp_port || 22,
port: parseInt(config.ftp_port, 10) || 22,
username: config.ftp_user,
password: config.ftp_password
password: config.ftp_password,
readyTimeout: parseInt(process.env.SFTP_CONNECT_TIMEOUT || '8000', 10)
});
return sftp;
}
@@ -1215,7 +1273,7 @@ app.post('/api/user/update-ftp',
try {
const { ftp_host, ftp_port, ftp_user, ftp_password, http_download_base_url } = req.body;
// 调试日志:查看接收到的配置
// 调试日志:查看接收到的配置(掩码密码)
console.log("[DEBUG] 收到SFTP配置:", {
ftp_host,
ftp_port,
@@ -1224,7 +1282,6 @@ app.post('/api/user/update-ftp',
http_download_base_url
});
// 如果用户已配置FTP且密码为空使用现有密码
let actualPassword = ftp_password;
if (!ftp_password && req.user.has_ftp_config && req.user.ftp_password) {
@@ -1236,9 +1293,12 @@ app.post('/api/user/update-ftp',
});
}
// 主机校验与防SSRF
const { port: safePort } = await validateSftpDestination(ftp_host, ftp_port);
// 验证FTP连接
try {
const sftp = await connectToSFTP({ ftp_host, ftp_port, ftp_user, ftp_password: actualPassword });
const sftp = await connectToSFTP({ ftp_host, ftp_port: safePort, ftp_user, ftp_password: actualPassword });
await sftp.end();
} catch (error) {
return res.status(400).json({
@@ -1250,7 +1310,7 @@ app.post('/api/user/update-ftp',
// 更新用户配置
UserDB.update(req.user.id, {
ftp_host,
ftp_port,
ftp_port: safePort,
ftp_user,
ftp_password: actualPassword,
http_download_base_url: http_download_base_url || null,

View File

@@ -921,9 +921,9 @@ handleDragLeave(e) {
? `/${file.name}`
: `${this.currentPath}/${file.name}`;
// 使用<a>标签下载,通过URL参数传递token
// 使用<a>标签下载,依赖同域 Cookie/凭证
const link = document.createElement('a');
link.href = `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}&token=${this.token}`;
link.href = `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
link.setAttribute('download', file.name);
document.body.appendChild(link);
link.click();
@@ -1143,8 +1143,8 @@ handleDragLeave(e) {
return file.httpDownloadUrl;
}
// 本地存储或未配置HTTP URL使用API下载
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}&token=${this.token}`;
// 本地存储或未配置HTTP URL使用API下载(同域 Cookie 验证)
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
},
// 获取文件缩略图URL
@@ -1242,7 +1242,7 @@ handleDragLeave(e) {
// 使用<a>标签下载通过URL参数传递token浏览器会显示下载进度
const link = document.createElement('a');
link.href = `${this.apiBase}/api/upload/download-tool?token=${this.token}`;
link.href = `${this.apiBase}/api/upload/download-tool`;
link.setAttribute('download', `玩玩云上传工具_${this.user.username}.zip`);
document.body.appendChild(link);
link.click();