615 lines
18 KiB
JavaScript
615 lines
18 KiB
JavaScript
/**
|
||
* 分享功能边界条件深度测试(兼容 Cookie + CSRF)
|
||
*
|
||
* 测试场景:
|
||
* 1. 已过期的分享
|
||
* 2. 分享文件不存在
|
||
* 3. 被封禁用户的分享
|
||
* 4. 路径遍历攻击
|
||
* 5. 特殊字符路径
|
||
* 6. 并发密码尝试
|
||
* 7. 分享统计
|
||
* 8. 分享码唯一性
|
||
* 9. 过期时间格式
|
||
*/
|
||
|
||
const http = require('http');
|
||
const https = require('https');
|
||
const bcrypt = require('bcryptjs');
|
||
const { db, ShareDB, UserDB } = require('./database');
|
||
|
||
const BASE_URL = process.env.TEST_BASE_URL || 'http://127.0.0.1:40001';
|
||
|
||
const results = {
|
||
passed: 0,
|
||
failed: 0,
|
||
errors: []
|
||
};
|
||
|
||
const adminSession = {
|
||
cookies: {},
|
||
csrfToken: ''
|
||
};
|
||
|
||
let adminUserId = 1;
|
||
|
||
function makeCookieHeader(cookies) {
|
||
return Object.entries(cookies || {})
|
||
.map(([k, v]) => `${k}=${v}`)
|
||
.join('; ');
|
||
}
|
||
|
||
function storeSetCookies(session, setCookieHeader) {
|
||
if (!session || !setCookieHeader) return;
|
||
const list = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||
for (const raw of list) {
|
||
const first = String(raw || '').split(';')[0];
|
||
const idx = first.indexOf('=');
|
||
if (idx <= 0) continue;
|
||
const key = first.slice(0, idx).trim();
|
||
const value = first.slice(idx + 1).trim();
|
||
session.cookies[key] = value;
|
||
if (key === 'csrf_token') {
|
||
session.csrfToken = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
function isSafeMethod(method) {
|
||
const upper = String(method || '').toUpperCase();
|
||
return upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS';
|
||
}
|
||
|
||
function request(method, path, options = {}) {
|
||
const {
|
||
data = null,
|
||
session = null,
|
||
headers = {},
|
||
requireCsrf = true
|
||
} = options;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const url = new URL(path, BASE_URL);
|
||
const transport = url.protocol === 'https:' ? https : http;
|
||
|
||
const requestHeaders = { ...headers };
|
||
|
||
if (session) {
|
||
const cookieHeader = makeCookieHeader(session.cookies);
|
||
if (cookieHeader) {
|
||
requestHeaders.Cookie = cookieHeader;
|
||
}
|
||
|
||
if (requireCsrf && !isSafeMethod(method)) {
|
||
const csrfToken = session.csrfToken || session.cookies.csrf_token;
|
||
if (csrfToken) {
|
||
requestHeaders['X-CSRF-Token'] = csrfToken;
|
||
}
|
||
}
|
||
}
|
||
|
||
let payload = null;
|
||
if (data !== null && data !== undefined) {
|
||
payload = JSON.stringify(data);
|
||
requestHeaders['Content-Type'] = 'application/json';
|
||
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
|
||
}
|
||
|
||
const req = transport.request({
|
||
protocol: url.protocol,
|
||
hostname: url.hostname,
|
||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||
path: url.pathname + url.search,
|
||
method,
|
||
headers: requestHeaders
|
||
}, (res) => {
|
||
let body = '';
|
||
res.on('data', chunk => {
|
||
body += chunk;
|
||
});
|
||
res.on('end', () => {
|
||
if (session) {
|
||
storeSetCookies(session, res.headers['set-cookie']);
|
||
}
|
||
|
||
let parsed = body;
|
||
try {
|
||
parsed = body ? JSON.parse(body) : {};
|
||
} catch (e) {
|
||
// keep raw text
|
||
}
|
||
|
||
resolve({
|
||
status: res.statusCode,
|
||
data: parsed,
|
||
headers: res.headers
|
||
});
|
||
});
|
||
});
|
||
|
||
req.on('error', reject);
|
||
|
||
if (payload) {
|
||
req.write(payload);
|
||
}
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
async function initCsrf(session) {
|
||
const res = await request('GET', '/api/csrf-token', {
|
||
session,
|
||
requireCsrf: false
|
||
});
|
||
|
||
if (res.status === 200 && res.data && res.data.csrfToken) {
|
||
session.csrfToken = res.data.csrfToken;
|
||
}
|
||
|
||
return res;
|
||
}
|
||
|
||
function assert(condition, message) {
|
||
if (condition) {
|
||
results.passed++;
|
||
console.log(` [PASS] ${message}`);
|
||
} else {
|
||
results.failed++;
|
||
results.errors.push(message);
|
||
console.log(` [FAIL] ${message}`);
|
||
}
|
||
}
|
||
|
||
function createValidShareCode(prefix = '') {
|
||
for (let i = 0; i < 20; i++) {
|
||
const generated = ShareDB.generateShareCode();
|
||
const code = (prefix + generated).replace(/[^A-Za-z0-9]/g, '').slice(0, 16);
|
||
if (!code || code.length < 6) continue;
|
||
const exists = db.prepare('SELECT 1 FROM shares WHERE share_code = ?').get(code);
|
||
if (!exists) return code;
|
||
}
|
||
|
||
return ShareDB.generateShareCode();
|
||
}
|
||
|
||
async function testExpiredShare() {
|
||
console.log('\n[测试] 已过期的分享...');
|
||
|
||
const expiredShareCode = createValidShareCode('E');
|
||
|
||
try {
|
||
const yesterday = new Date();
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
const expiresAt = yesterday.toISOString().replace('T', ' ').substring(0, 19);
|
||
|
||
db.prepare(`
|
||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type, expires_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
`).run(adminUserId, expiredShareCode, '/expired-test.txt', 'file', 'local', expiresAt);
|
||
|
||
console.log(` 创建过期分享: ${expiredShareCode}, 过期时间: ${expiresAt}`);
|
||
|
||
const res = await request('POST', `/api/share/${expiredShareCode}/verify`, {
|
||
data: {},
|
||
requireCsrf: false
|
||
});
|
||
|
||
assert(res.status === 404, `过期分享应返回 404, 实际: ${res.status}`);
|
||
assert(res.data && res.data.message === '分享不存在', '应提示分享不存在');
|
||
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(expiredShareCode);
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
results.failed++;
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(expiredShareCode);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function testShareWithDeletedFile() {
|
||
console.log('\n[测试] 分享的文件不存在...');
|
||
|
||
const shareCode = createValidShareCode('N');
|
||
|
||
try {
|
||
db.prepare(`
|
||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`).run(adminUserId, shareCode, '/non_existent_file_xyz.txt', 'file', 'local');
|
||
|
||
console.log(` 创建分享: ${shareCode}, 路径: /non_existent_file_xyz.txt`);
|
||
|
||
const res = await request('POST', `/api/share/${shareCode}/verify`, {
|
||
data: {},
|
||
requireCsrf: false
|
||
});
|
||
|
||
assert(res.status === 500 || res.status === 200, `应返回500或200,实际: ${res.status}`);
|
||
if (res.status === 500) {
|
||
assert(!!res.data?.message, '500时应返回错误消息');
|
||
}
|
||
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function testShareByBannedUser() {
|
||
console.log('\n[测试] 被封禁用户的分享...');
|
||
|
||
let testUserId = null;
|
||
const shareCode = createValidShareCode('B');
|
||
|
||
try {
|
||
testUserId = UserDB.create({
|
||
username: `test_banned_${Date.now()}`,
|
||
email: `test_banned_${Date.now()}@test.com`,
|
||
password: 'test123',
|
||
is_verified: 1
|
||
});
|
||
|
||
db.prepare(`
|
||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`).run(testUserId, shareCode, '/test.txt', 'file', 'local');
|
||
|
||
console.log(` 创建测试用户 ID: ${testUserId}`);
|
||
console.log(` 创建分享: ${shareCode}`);
|
||
|
||
UserDB.setBanStatus(testUserId, true);
|
||
console.log(` 封禁用户: ${testUserId}`);
|
||
|
||
const res = await request('POST', `/api/share/${shareCode}/verify`, {
|
||
data: {},
|
||
requireCsrf: false
|
||
});
|
||
|
||
console.log(` 被封禁用户分享访问状态码: ${res.status}`);
|
||
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
UserDB.delete(testUserId);
|
||
|
||
assert(true, '被封禁用户分享测试完成');
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
if (shareCode) db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
if (testUserId) UserDB.delete(testUserId);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function testPathTraversalAttacks() {
|
||
console.log('\n[测试] 路径遍历攻击防护...');
|
||
|
||
const shareCode = createValidShareCode('T');
|
||
|
||
try {
|
||
db.prepare(`
|
||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`).run(adminUserId, shareCode, '/allowed-folder', 'directory', 'local');
|
||
|
||
const attackPaths = [
|
||
'../../../etc/passwd',
|
||
'..\\..\\..\\etc\\passwd',
|
||
'%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd',
|
||
'/allowed-folder/../../../etc/passwd',
|
||
'/allowed-folder/./../../etc/passwd',
|
||
'....//....//....//etc/passwd',
|
||
'/allowed-folder%00.txt/../../../etc/passwd'
|
||
];
|
||
|
||
let blocked = 0;
|
||
for (const attackPath of attackPaths) {
|
||
const res = await request('POST', `/api/share/${shareCode}/download-url`, {
|
||
data: { path: attackPath },
|
||
requireCsrf: false
|
||
});
|
||
|
||
if (res.status === 403 || res.status === 400) {
|
||
blocked++;
|
||
console.log(` [BLOCKED] ${attackPath.substring(0, 40)}...`);
|
||
} else {
|
||
console.log(` [WARN] 可能未阻止: ${attackPath}, 状态: ${res.status}`);
|
||
}
|
||
}
|
||
|
||
assert(blocked >= attackPaths.length - 1, `路径遍历攻击应被阻止 (${blocked}/${attackPaths.length})`);
|
||
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function testSpecialCharactersInPath() {
|
||
console.log('\n[测试] 特殊字符路径处理...');
|
||
|
||
if (!adminSession.cookies.token) {
|
||
console.log(' [WARN] 未登录,跳过特殊字符路径测试');
|
||
assert(true, '未登录时跳过特殊字符路径测试');
|
||
return true;
|
||
}
|
||
|
||
const specialPaths = [
|
||
'/文件夹/中文文件.txt',
|
||
'/folder with spaces/file.txt',
|
||
'/folder-with-dashes/file_underscore.txt',
|
||
'/folder.with.dots/file.name.ext.txt',
|
||
"/folder'with'quotes/file.txt"
|
||
];
|
||
|
||
let handled = 0;
|
||
|
||
for (const virtualPath of specialPaths) {
|
||
try {
|
||
const createRes = await request('POST', '/api/share/create', {
|
||
data: {
|
||
share_type: 'file',
|
||
file_path: virtualPath
|
||
},
|
||
session: adminSession
|
||
});
|
||
|
||
if (createRes.status === 200 || createRes.status === 400) {
|
||
handled++;
|
||
console.log(` [OK] ${virtualPath.substring(0, 30)}... - 状态: ${createRes.status}`);
|
||
|
||
if (createRes.status === 200 && createRes.data?.share_code) {
|
||
const mySharesRes = await request('GET', '/api/share/my', {
|
||
session: adminSession
|
||
});
|
||
|
||
const share = mySharesRes.data?.shares?.find(s => s.share_code === createRes.data.share_code);
|
||
if (share) {
|
||
await request('DELETE', `/api/share/${share.id}`, {
|
||
session: adminSession
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${virtualPath}: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
assert(handled === specialPaths.length, '特殊字符路径处理完成');
|
||
return true;
|
||
}
|
||
|
||
async function testConcurrentPasswordAttempts() {
|
||
console.log('\n[测试] 并发密码尝试限流...');
|
||
|
||
const shareCode = createValidShareCode('C');
|
||
|
||
try {
|
||
const hashedPassword = bcrypt.hashSync('correct123', 10);
|
||
|
||
db.prepare(`
|
||
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, storage_type)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
`).run(adminUserId, shareCode, '/test.txt', 'file', hashedPassword, 'local');
|
||
|
||
const promises = [];
|
||
for (let i = 0; i < 20; i++) {
|
||
promises.push(
|
||
request('POST', `/api/share/${shareCode}/verify`, {
|
||
data: { password: `wrong${i}` },
|
||
requireCsrf: false
|
||
})
|
||
);
|
||
}
|
||
|
||
const responses = await Promise.all(promises);
|
||
|
||
const rateLimited = responses.filter(r => r.status === 429).length;
|
||
const unauthorized = responses.filter(r => r.status === 401).length;
|
||
|
||
console.log(` 并发请求: 20, 限流: ${rateLimited}, 401错误: ${unauthorized}`);
|
||
|
||
if (rateLimited > 0) {
|
||
assert(true, '限流机制生效');
|
||
} else {
|
||
console.log(' [INFO] 限流未触发(可能配置较宽松)');
|
||
assert(true, '并发测试完成');
|
||
}
|
||
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function testShareStatistics() {
|
||
console.log('\n[测试] 分享统计功能...');
|
||
|
||
const shareCode = createValidShareCode('S');
|
||
|
||
try {
|
||
db.prepare(`
|
||
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type, view_count, download_count)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`).run(adminUserId, shareCode, '/test.txt', 'file', 'local', 0, 0);
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
await request('POST', `/api/share/${shareCode}/verify`, {
|
||
data: {},
|
||
requireCsrf: false
|
||
});
|
||
}
|
||
|
||
for (let i = 0; i < 2; i++) {
|
||
await request('POST', `/api/share/${shareCode}/download`, {
|
||
data: {},
|
||
requireCsrf: false
|
||
});
|
||
}
|
||
|
||
const share = db.prepare('SELECT view_count, download_count FROM shares WHERE share_code = ?').get(shareCode);
|
||
|
||
assert(share.view_count === 3, `查看次数应为 3, 实际: ${share.view_count}`);
|
||
assert(share.download_count === 2, `下载次数应为 2, 实际: ${share.download_count}`);
|
||
|
||
console.log(` 查看次数: ${share.view_count}, 下载次数: ${share.download_count}`);
|
||
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function testShareCodeUniqueness() {
|
||
console.log('\n[测试] 分享码唯一性...');
|
||
|
||
try {
|
||
const codes = new Set();
|
||
|
||
for (let i = 0; i < 10; i++) {
|
||
const code = ShareDB.generateShareCode();
|
||
if (codes.has(code)) {
|
||
console.log(` [WARN] 发现重复分享码: ${code}`);
|
||
}
|
||
codes.add(code);
|
||
}
|
||
|
||
assert(codes.size === 10, `应生成 10 个唯一分享码, 实际: ${codes.size}`);
|
||
console.log(` 生成了 ${codes.size} 个唯一分享码`);
|
||
|
||
const sampleCode = ShareDB.generateShareCode();
|
||
assert(sampleCode.length === 8, `分享码长度应为 8, 实际: ${sampleCode.length}`);
|
||
assert(/^[a-zA-Z0-9]+$/.test(sampleCode), '分享码应只包含字母数字');
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function testExpiryTimeFormat() {
|
||
console.log('\n[测试] 过期时间格式...');
|
||
|
||
try {
|
||
const testDays = [1, 7, 30, 365];
|
||
|
||
for (const days of testDays) {
|
||
const result = ShareDB.create(adminUserId, {
|
||
share_type: 'file',
|
||
file_path: `/test_${days}_days.txt`,
|
||
expiry_days: days
|
||
});
|
||
|
||
const share = db.prepare('SELECT expires_at FROM shares WHERE share_code = ?').get(result.share_code);
|
||
|
||
const expiresAt = new Date(share.expires_at);
|
||
const now = new Date();
|
||
const diffDays = Math.round((expiresAt - now) / (1000 * 60 * 60 * 24));
|
||
|
||
assert(Math.abs(diffDays - days) <= 1, `${days}天过期应正确设置, 实际差异: ${diffDays}天`);
|
||
|
||
db.prepare('DELETE FROM shares WHERE share_code = ?').run(result.share_code);
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function login() {
|
||
console.log('\n[准备] 登录获取认证...');
|
||
|
||
try {
|
||
await initCsrf(adminSession);
|
||
|
||
const res = await request('POST', '/api/login', {
|
||
data: {
|
||
username: 'admin',
|
||
password: 'admin123'
|
||
},
|
||
session: adminSession,
|
||
requireCsrf: false
|
||
});
|
||
|
||
if (res.status === 200 && res.data?.success && adminSession.cookies.token) {
|
||
await initCsrf(adminSession);
|
||
|
||
const profileRes = await request('GET', '/api/user/profile', {
|
||
session: adminSession
|
||
});
|
||
|
||
if (profileRes.status === 200 && profileRes.data?.user?.id) {
|
||
adminUserId = profileRes.data.user.id;
|
||
}
|
||
|
||
console.log(' 认证成功');
|
||
return true;
|
||
}
|
||
|
||
console.log(` 认证失败: status=${res.status}, message=${res.data?.message || 'unknown'}`);
|
||
return false;
|
||
} catch (error) {
|
||
console.log(` [ERROR] ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function runTests() {
|
||
console.log('========================================');
|
||
console.log(' 分享功能边界条件深度测试(Cookie + CSRF)');
|
||
console.log('========================================');
|
||
|
||
const loggedIn = await login();
|
||
if (!loggedIn) {
|
||
console.log('\n[WARN] 登录失败,部分测试可能无法执行');
|
||
}
|
||
|
||
await testExpiredShare();
|
||
await testShareWithDeletedFile();
|
||
await testShareByBannedUser();
|
||
await testPathTraversalAttacks();
|
||
await testSpecialCharactersInPath();
|
||
await testConcurrentPasswordAttempts();
|
||
await testShareStatistics();
|
||
await testShareCodeUniqueness();
|
||
await testExpiryTimeFormat();
|
||
|
||
console.log('\n========================================');
|
||
console.log(' 测试结果统计');
|
||
console.log('========================================');
|
||
console.log(`通过: ${results.passed}`);
|
||
console.log(`失败: ${results.failed}`);
|
||
|
||
if (results.errors.length > 0) {
|
||
console.log('\n失败的测试:');
|
||
results.errors.forEach((err, i) => {
|
||
console.log(` ${i + 1}. ${err}`);
|
||
});
|
||
}
|
||
|
||
console.log('\n========================================');
|
||
|
||
process.exit(results.failed > 0 ? 1 : 0);
|
||
}
|
||
|
||
runTests().catch(error => {
|
||
console.error('测试执行失败:', error);
|
||
process.exit(1);
|
||
});
|