diff --git a/backend/auth.js b/backend/auth.js
index d5c65ee..a52c70c 100644
--- a/backend/auth.js
+++ b/backend/auth.js
@@ -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({
diff --git a/backend/server.js b/backend/server.js
index 0c8bfeb..1beda9d 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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,
diff --git a/frontend/app.js b/frontend/app.js
index 11b4aad..166d69e 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -921,9 +921,9 @@ handleDragLeave(e) {
? `/${file.name}`
: `${this.currentPath}/${file.name}`;
- // 使用标签下载,通过URL参数传递token
+ // 使用标签下载,依赖同域 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) {
// 使用标签下载,通过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();