fix: harden cloud storage security

This commit is contained in:
237899745
2026-06-13 18:45:12 +08:00
parent 7943b04ee2
commit bb6ad01018
28 changed files with 2229 additions and 996 deletions

View File

@@ -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('原型污染尝试应该被阻止', () => {

View 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);
});