fix: harden cloud storage security
This commit is contained in:
@@ -473,7 +473,7 @@ function testLocalStoragePath() {
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
const basePath = '/tmp/storage/user_1';
|
||||
const basePath = path.join(path.resolve('/tmp'), 'storage', 'user_1');
|
||||
|
||||
test('正常相对路径应该被接受', () => {
|
||||
const result = getFullPath(basePath, 'documents/file.txt');
|
||||
@@ -703,7 +703,7 @@ function testDatabaseFieldWhitelist() {
|
||||
const ALLOWED_FIELDS = [
|
||||
'username', 'email', 'password',
|
||||
'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint',
|
||||
'upload_api_key', 'is_admin', 'is_active', 'is_banned', 'has_oss_config',
|
||||
'upload_api_key', 'is_active', 'is_banned', 'has_oss_config',
|
||||
'is_verified', 'verification_token', 'verification_expires_at',
|
||||
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
|
||||
'theme_preference'
|
||||
@@ -730,14 +730,14 @@ function testDatabaseFieldWhitelist() {
|
||||
const updates = {
|
||||
username: 'newname',
|
||||
id: 999, // 尝试修改 ID
|
||||
is_admin: 1, // 合法字段
|
||||
is_admin: 1, // 权限字段不允许通过通用更新入口修改
|
||||
sql_injection: "'; DROP TABLE users; --" // 非法字段
|
||||
};
|
||||
const filtered = filterUpdates(updates);
|
||||
assert.ok(!('id' in filtered));
|
||||
assert.ok(!('is_admin' in filtered));
|
||||
assert.ok(!('sql_injection' in filtered));
|
||||
assert.strictEqual(filtered.username, 'newname');
|
||||
assert.strictEqual(filtered.is_admin, 1);
|
||||
});
|
||||
|
||||
test('原型污染尝试应该被阻止', () => {
|
||||
|
||||
420
backend/tests/full-audit-regression.js
Normal file
420
backend/tests/full-audit-regression.js
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Full project audit regression harness.
|
||||
*
|
||||
* Starts the backend with an isolated database/storage root and exercises the
|
||||
* highest-risk public HTTP flows through real routes, cookies and CSRF.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const BACKEND_DIR = path.resolve(__dirname, '..');
|
||||
const SERVER_PATH = path.join(BACKEND_DIR, 'server.js');
|
||||
const AUDIT_PREFIX = `audit_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
||||
|
||||
function randomHex(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString('hex');
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function countRegularFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isFile()) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
class CookieJar {
|
||||
constructor() {
|
||||
this.cookies = new Map();
|
||||
}
|
||||
|
||||
store(setCookie) {
|
||||
const list = Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : []);
|
||||
for (const raw of list) {
|
||||
const first = String(raw).split(';')[0];
|
||||
const idx = first.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
this.cookies.set(first.slice(0, idx), first.slice(idx + 1));
|
||||
}
|
||||
}
|
||||
|
||||
header() {
|
||||
return Array.from(this.cookies.entries())
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
get(name) {
|
||||
return this.cookies.get(name) || '';
|
||||
}
|
||||
}
|
||||
|
||||
async function request(baseUrl, jar, method, route, options = {}) {
|
||||
const url = new URL(route, baseUrl);
|
||||
const headers = { ...(options.headers || {}) };
|
||||
const cookieHeader = jar?.header();
|
||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||
|
||||
const upperMethod = method.toUpperCase();
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(upperMethod) && options.csrf !== false) {
|
||||
const csrf = jar?.get('csrf_token');
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
}
|
||||
|
||||
let body = options.body;
|
||||
if (options.json !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(options.json);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: upperMethod,
|
||||
headers,
|
||||
body,
|
||||
redirect: options.redirect || 'manual'
|
||||
});
|
||||
|
||||
jar?.store(response.headers.getSetCookie ? response.headers.getSetCookie() : response.headers.get('set-cookie'));
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
let data = buffer;
|
||||
if (contentType.includes('application/json')) {
|
||||
data = JSON.parse(buffer.toString('utf8') || '{}');
|
||||
} else if (contentType.includes('text/') || contentType.includes('image/svg')) {
|
||||
data = buffer.toString('utf8');
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
data,
|
||||
raw: buffer
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHealth(baseUrl, timeoutMs = 15000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const res = await request(baseUrl, null, 'GET', '/api/health');
|
||||
if (res.status === 200 && res.data?.success === true) return;
|
||||
} catch {}
|
||||
await delay(250);
|
||||
}
|
||||
throw new Error('server did not become healthy');
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-full-audit-'));
|
||||
const storageRoot = path.join(tempRoot, 'storage');
|
||||
const dbPath = path.join(tempRoot, 'database.db');
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const uploadsDir = path.join(BACKEND_DIR, 'uploads');
|
||||
const adminPassword = `${AUDIT_PREFIX}_Pass123!`;
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
NODE_ENV: 'development',
|
||||
DATABASE_PATH: dbPath,
|
||||
STORAGE_ROOT: storageRoot,
|
||||
JWT_SECRET: randomHex(32),
|
||||
ENCRYPTION_KEY: randomHex(32),
|
||||
ADMIN_USERNAME: 'admin',
|
||||
ADMIN_PASSWORD: adminPassword,
|
||||
PUBLIC_BASE_URL: baseUrl,
|
||||
ALLOWED_ORIGINS: baseUrl,
|
||||
COOKIE_SECURE: 'false',
|
||||
ENABLE_CSRF: 'true',
|
||||
ENFORCE_HTTPS: 'false',
|
||||
TRUST_PROXY: 'false',
|
||||
WAL_CHECKPOINT_ENABLED: 'false'
|
||||
};
|
||||
|
||||
const child = spawn(process.execPath, [SERVER_PATH], {
|
||||
cwd: BACKEND_DIR,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push({ name, fn });
|
||||
|
||||
try {
|
||||
await waitForHealth(baseUrl);
|
||||
|
||||
const jar = new CookieJar();
|
||||
let userId = 1;
|
||||
let shareId = null;
|
||||
let shareCode = '';
|
||||
let directLinkId = null;
|
||||
|
||||
test('public health/config/csrf endpoints are reachable', async () => {
|
||||
const health = await request(baseUrl, jar, 'GET', '/api/health');
|
||||
assert.strictEqual(health.status, 200);
|
||||
assert.strictEqual(health.data.success, true);
|
||||
|
||||
const config = await request(baseUrl, jar, 'GET', '/api/config');
|
||||
assert.strictEqual(config.status, 200);
|
||||
assert.strictEqual(config.data.success, true);
|
||||
|
||||
const csrf = await request(baseUrl, jar, 'GET', '/api/csrf-token');
|
||||
assert.strictEqual(csrf.status, 200);
|
||||
assert.ok(csrf.data.csrfToken);
|
||||
assert.ok(jar.get('csrf_token'));
|
||||
});
|
||||
|
||||
test('auth endpoints login with real cookies and enforce CSRF after authentication', async () => {
|
||||
const login = await request(baseUrl, jar, 'POST', '/api/login', {
|
||||
json: { username: 'admin', password: adminPassword }
|
||||
});
|
||||
assert.strictEqual(login.status, 200);
|
||||
assert.strictEqual(login.data.success, true);
|
||||
assert.ok(jar.get('token'));
|
||||
assert.ok(jar.get('refreshToken'));
|
||||
userId = login.data.user.id;
|
||||
|
||||
const profile = await request(baseUrl, jar, 'GET', '/api/user/profile');
|
||||
assert.strictEqual(profile.status, 200);
|
||||
assert.strictEqual(profile.data.success, true);
|
||||
assert.strictEqual(profile.data.user.id, userId);
|
||||
assert.strictEqual(profile.data.user.oss_access_key_secret, undefined);
|
||||
|
||||
const csrfBlocked = await request(baseUrl, jar, 'POST', '/api/user/theme', {
|
||||
csrf: false,
|
||||
json: { theme: 'light' }
|
||||
});
|
||||
assert.strictEqual(csrfBlocked.status, 403);
|
||||
});
|
||||
|
||||
test('admin can move isolated audit user to local storage only', async () => {
|
||||
const res = await request(baseUrl, jar, 'POST', `/api/admin/users/${userId}/storage-permission`, {
|
||||
json: {
|
||||
storage_permission: 'local_only',
|
||||
local_storage_quota: 10 * 1024 * 1024,
|
||||
download_traffic_quota: -1,
|
||||
reset_download_traffic_used: true
|
||||
}
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.data.success, true);
|
||||
assert.strictEqual(res.data.user.current_storage_type, 'local');
|
||||
});
|
||||
|
||||
test('file manager rejects unsafe folder names and handles normal local file flow', async () => {
|
||||
const badMkdir = await request(baseUrl, jar, 'POST', '/api/files/mkdir', {
|
||||
json: { path: '/', folderName: '../bad' }
|
||||
});
|
||||
assert.strictEqual(badMkdir.status, 400);
|
||||
|
||||
const badList = await request(baseUrl, jar, 'GET', '/api/files?path=/../secret');
|
||||
assert.strictEqual(badList.status, 400);
|
||||
|
||||
const mkdir = await request(baseUrl, jar, 'POST', '/api/files/mkdir', {
|
||||
json: { path: '/', folderName: AUDIT_PREFIX }
|
||||
});
|
||||
assert.strictEqual(mkdir.status, 200);
|
||||
assert.strictEqual(mkdir.data.success, true);
|
||||
|
||||
const file = new Blob([`hello ${AUDIT_PREFIX}`], { type: 'text/plain' });
|
||||
const form = new FormData();
|
||||
form.append('path', `/${AUDIT_PREFIX}`);
|
||||
form.append('file', file, 'hello.txt');
|
||||
const upload = await request(baseUrl, jar, 'POST', '/api/upload', { body: form });
|
||||
assert.strictEqual(upload.status, 200);
|
||||
assert.strictEqual(upload.data.success, true);
|
||||
assert.strictEqual(upload.data.path, `/${AUDIT_PREFIX}/hello.txt`);
|
||||
|
||||
const list = await request(baseUrl, jar, 'GET', `/api/files?path=/${encodeURIComponent(AUDIT_PREFIX)}`);
|
||||
assert.strictEqual(list.status, 200);
|
||||
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
|
||||
|
||||
const search = await request(baseUrl, jar, 'GET', `/api/files/search?keyword=hello&path=/${encodeURIComponent(AUDIT_PREFIX)}`);
|
||||
assert.strictEqual(search.status, 200);
|
||||
assert.ok(search.data.items.some(item => item.name === 'hello.txt'));
|
||||
});
|
||||
|
||||
test('failed normal upload validation must not leave multer temp files', async () => {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
const before = countRegularFiles(uploadsDir);
|
||||
const form = new FormData();
|
||||
form.append('path', '/../blocked');
|
||||
form.append('file', new Blob(['leak candidate'], { type: 'text/plain' }), 'leak.txt');
|
||||
const res = await request(baseUrl, jar, 'POST', '/api/upload', { body: form });
|
||||
assert.strictEqual(res.status, 400);
|
||||
const after = countRegularFiles(uploadsDir);
|
||||
assert.strictEqual(after, before, `uploads temp leak: before=${before}, after=${after}`);
|
||||
});
|
||||
|
||||
test('download URL/check/download work for local file and reject traversal', async () => {
|
||||
const traversal = await request(baseUrl, jar, 'GET', '/api/files/download-check?path=/../secret.txt');
|
||||
assert.strictEqual(traversal.status, 400);
|
||||
|
||||
const check = await request(baseUrl, jar, 'GET', `/api/files/download-check?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
|
||||
assert.strictEqual(check.status, 200);
|
||||
assert.strictEqual(check.data.success, true);
|
||||
|
||||
const url = await request(baseUrl, jar, 'GET', `/api/files/download-url?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt&mode=download`);
|
||||
assert.strictEqual(url.status, 400);
|
||||
assert.match(url.data.message, /OSS/);
|
||||
|
||||
const download = await request(baseUrl, jar, 'GET', `/api/files/download?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
|
||||
assert.strictEqual(download.status, 200);
|
||||
assert.ok(download.raw.toString('utf8').includes(AUDIT_PREFIX));
|
||||
});
|
||||
|
||||
test('share and direct-link flows preserve path boundaries', async () => {
|
||||
const createShare = await request(baseUrl, jar, 'POST', '/api/share/create', {
|
||||
json: {
|
||||
share_type: 'directory',
|
||||
file_path: `/${AUDIT_PREFIX}`,
|
||||
file_name: AUDIT_PREFIX,
|
||||
password: `${AUDIT_PREFIX}_pw`,
|
||||
expiry_days: 1,
|
||||
max_downloads: 5,
|
||||
device_limit: 'all'
|
||||
}
|
||||
});
|
||||
assert.strictEqual(createShare.status, 200);
|
||||
assert.strictEqual(createShare.data.success, true);
|
||||
shareId = createShare.data.share_id;
|
||||
shareCode = createShare.data.share_code;
|
||||
|
||||
const badVerify = await request(baseUrl, new CookieJar(), 'POST', `/api/share/${shareCode}/verify`, {
|
||||
json: { password: 'wrong' }
|
||||
});
|
||||
assert.strictEqual(badVerify.status, 401);
|
||||
|
||||
const publicJar = new CookieJar();
|
||||
const verify = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/verify`, {
|
||||
json: { password: `${AUDIT_PREFIX}_pw` }
|
||||
});
|
||||
assert.strictEqual(verify.status, 200);
|
||||
assert.strictEqual(verify.data.success, true);
|
||||
|
||||
const list = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/list`, {
|
||||
json: { path: '', password: `${AUDIT_PREFIX}_pw` }
|
||||
});
|
||||
assert.strictEqual(list.status, 200);
|
||||
assert.strictEqual(list.data.success, true);
|
||||
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
|
||||
|
||||
const traversal = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/download-url`, {
|
||||
json: { path: '/../database.db', mode: 'download', password: `${AUDIT_PREFIX}_pw` }
|
||||
});
|
||||
assert.ok([400, 403, 404].includes(traversal.status));
|
||||
|
||||
const direct = await request(baseUrl, jar, 'POST', '/api/direct-link/create', {
|
||||
json: {
|
||||
file_path: `/${AUDIT_PREFIX}/hello.txt`,
|
||||
file_name: 'hello.txt',
|
||||
expiry_days: 1
|
||||
}
|
||||
});
|
||||
assert.strictEqual(direct.status, 200);
|
||||
assert.strictEqual(direct.data.success, true);
|
||||
directLinkId = direct.data.link_id;
|
||||
});
|
||||
|
||||
test('admin listing/logging endpoints are authenticated and sanitized', async () => {
|
||||
const noAuth = await request(baseUrl, new CookieJar(), 'GET', '/api/admin/users');
|
||||
assert.strictEqual(noAuth.status, 401);
|
||||
|
||||
const users = await request(baseUrl, jar, 'GET', '/api/admin/users?page=1&pageSize=10');
|
||||
assert.strictEqual(users.status, 200);
|
||||
assert.strictEqual(users.data.success, true);
|
||||
const adminRow = users.data.users.find(user => user.id === userId);
|
||||
assert.ok(adminRow);
|
||||
assert.strictEqual(adminRow.password, undefined);
|
||||
assert.strictEqual(adminRow.oss_access_key_secret, undefined);
|
||||
|
||||
const logs = await request(baseUrl, jar, 'GET', '/api/admin/logs?page=1&pageSize=5');
|
||||
assert.strictEqual(logs.status, 200);
|
||||
assert.strictEqual(logs.data.success, true);
|
||||
});
|
||||
|
||||
test('cleanup via public APIs succeeds for audit artifacts', async () => {
|
||||
if (directLinkId) {
|
||||
const res = await request(baseUrl, jar, 'DELETE', `/api/direct-link/${directLinkId}`);
|
||||
assert.ok([200, 404].includes(res.status));
|
||||
}
|
||||
if (shareId) {
|
||||
const res = await request(baseUrl, jar, 'DELETE', `/api/share/${shareId}`);
|
||||
assert.ok([200, 404].includes(res.status));
|
||||
}
|
||||
const del = await request(baseUrl, jar, 'POST', '/api/files/delete', {
|
||||
json: { path: '/', fileName: AUDIT_PREFIX }
|
||||
});
|
||||
assert.strictEqual(del.status, 200);
|
||||
assert.strictEqual(del.data.success, true);
|
||||
});
|
||||
|
||||
const failures = [];
|
||||
for (const item of tests) {
|
||||
try {
|
||||
await item.fn();
|
||||
console.log(`[PASS] ${item.name}`);
|
||||
} catch (error) {
|
||||
failures.push({ name: item.name, error });
|
||||
console.error(`[FAIL] ${item.name}`);
|
||||
console.error(error.stack || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const summary = failures.map(item => `- ${item.name}: ${item.error.message}`).join('\n');
|
||||
throw new Error(`full audit regression failed:\n${summary}`);
|
||||
}
|
||||
|
||||
console.log(`PASS full-audit-regression (${tests.length} tests)`);
|
||||
} catch (error) {
|
||||
console.error('--- backend stdout tail ---');
|
||||
console.error(stdout.split(/\r?\n/).slice(-80).join('\n'));
|
||||
console.error('--- backend stderr tail ---');
|
||||
console.error(stderr.split(/\r?\n/).slice(-80).join('\n'));
|
||||
throw error;
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
await delay(500);
|
||||
if (!child.killed) child.kill('SIGKILL');
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(error => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user