421 lines
15 KiB
JavaScript
421 lines
15 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|