121 lines
4.4 KiB
JavaScript
121 lines
4.4 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.DATA_ENCRYPTION_SECRET = 'test-only-encryption-secret-with-more-than-32-characters';
|
|
|
|
const {
|
|
app,
|
|
encryptSensitiveValue,
|
|
decryptSensitiveValue,
|
|
hashSessionToken,
|
|
normalizeOptionalHttpUrl,
|
|
isPdfBuffer,
|
|
sanitizeDocumentFileName,
|
|
getDocumentContentDisposition
|
|
} = require('../backend/server');
|
|
|
|
async function withTestServer(run) {
|
|
const server = app.listen(0, '127.0.0.1');
|
|
await new Promise((resolve, reject) => {
|
|
server.once('listening', resolve);
|
|
server.once('error', reject);
|
|
});
|
|
|
|
try {
|
|
const address = server.address();
|
|
await run(`http://127.0.0.1:${address.port}`);
|
|
} finally {
|
|
await new Promise(resolve => server.close(resolve));
|
|
}
|
|
}
|
|
|
|
test('stored credentials use authenticated encryption and round-trip safely', () => {
|
|
const plainText = 'example-password-value';
|
|
const first = encryptSensitiveValue(plainText);
|
|
const second = encryptSensitiveValue(plainText);
|
|
|
|
assert.match(first, /^enc:v2:/);
|
|
assert.notEqual(first, second);
|
|
assert.equal(first.includes(plainText), false);
|
|
assert.equal(decryptSensitiveValue(first), plainText);
|
|
assert.equal(decryptSensitiveValue(`${first}tampered`), null);
|
|
});
|
|
|
|
test('session tokens are stored as deterministic SHA-256 hashes', () => {
|
|
const token = 'a-private-random-session-token';
|
|
const hash = hashSessionToken(token);
|
|
|
|
assert.match(hash, /^[a-f0-9]{64}$/);
|
|
assert.equal(hash, hashSessionToken(token));
|
|
assert.equal(hash.includes(token), false);
|
|
});
|
|
|
|
test('application URLs accept only HTTP and HTTPS protocols', () => {
|
|
assert.equal(normalizeOptionalHttpUrl(''), '');
|
|
assert.equal(normalizeOptionalHttpUrl('javascript:alert(1)'), null);
|
|
assert.equal(normalizeOptionalHttpUrl('file:///etc/passwd'), null);
|
|
assert.match(normalizeOptionalHttpUrl('https://example.com/path'), /^https:\/\/example\.com\/path/);
|
|
});
|
|
|
|
test('PDF uploads require a PDF header and end-of-file marker', () => {
|
|
assert.equal(isPdfBuffer(Buffer.from('%PDF-1.7\nbody\n%%EOF')), true);
|
|
assert.equal(isPdfBuffer(Buffer.from('%PDF-1.7\nbody without trailer')), false);
|
|
assert.equal(isPdfBuffer(Buffer.from('<html>not a pdf</html>')), false);
|
|
});
|
|
|
|
test('document filenames and response headers cannot inject control characters', () => {
|
|
const safeName = sanitizeDocumentFileName('bao/cao\r\nInjected: value');
|
|
const longName = sanitizeDocumentFileName('a'.repeat(300));
|
|
const disposition = getDocumentContentDisposition('Báo cáo tháng 8.pdf');
|
|
|
|
assert.equal(safeName, 'bao_caoInjected_ value.pdf');
|
|
assert.equal(longName.length, 255);
|
|
assert.match(longName, /\.pdf$/);
|
|
assert.match(disposition, /^inline; filename="/);
|
|
assert.match(disposition, /filename\*=UTF-8''/);
|
|
assert.equal(disposition.includes('\r'), false);
|
|
assert.equal(disposition.includes('\n'), false);
|
|
});
|
|
|
|
test('forged legacy identity headers cannot bypass protected APIs', async () => {
|
|
await withTestServer(async baseUrl => {
|
|
const response = await fetch(`${baseUrl}/api/users`, {
|
|
headers: {
|
|
'x-user-id': '1',
|
|
'x-user-role': 'admin'
|
|
}
|
|
});
|
|
const body = await response.json();
|
|
|
|
assert.equal(response.status, 401);
|
|
assert.equal(body.success, false);
|
|
});
|
|
});
|
|
|
|
test('security headers and no-store API caching are enabled', async () => {
|
|
await withTestServer(async baseUrl => {
|
|
const response = await fetch(`${baseUrl}/api/health`);
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.match(response.headers.get('content-security-policy') || '', /default-src 'self'/);
|
|
assert.equal(response.headers.get('x-powered-by'), null);
|
|
assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
});
|
|
});
|
|
|
|
test('state-changing requests from untrusted origins are rejected before route handling', async () => {
|
|
await withTestServer(async baseUrl => {
|
|
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Origin: 'https://attacker.example',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ username: 'test', password: 'not-a-real-password' })
|
|
});
|
|
|
|
assert.equal(response.status, 403);
|
|
});
|
|
});
|