WIP
This commit is contained in:
539
backend/tests/security/security.test.js
Normal file
539
backend/tests/security/security.test.js
Normal file
@ -0,0 +1,539 @@
|
||||
const request = require('supertest');
|
||||
const app = require('../../src/app');
|
||||
const User = require('../../src/models/User');
|
||||
const Bookmark = require('../../src/models/Bookmark');
|
||||
const dbConnection = require('../../src/database/connection');
|
||||
|
||||
describe('Security Tests', () => {
|
||||
let testUser;
|
||||
let authToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Clean up any existing test data
|
||||
await dbConnection.query('DELETE FROM users WHERE email LIKE $1', ['%security-test%']);
|
||||
|
||||
// Create test user
|
||||
testUser = await User.create({
|
||||
email: 'security-test@example.com',
|
||||
password: 'TestPassword123!'
|
||||
});
|
||||
await testUser.verifyEmail();
|
||||
|
||||
// Login to get auth token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: 'security-test@example.com',
|
||||
password: 'TestPassword123!'
|
||||
});
|
||||
|
||||
const cookies = loginResponse.headers['set-cookie'];
|
||||
authToken = cookies.find(cookie => cookie.includes('authToken')).split('authToken=')[1].split(';')[0];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test data
|
||||
await dbConnection.query('DELETE FROM bookmarks WHERE user_id = $1', [testUser.id]);
|
||||
await testUser.delete();
|
||||
await dbConnection.end();
|
||||
});
|
||||
|
||||
describe('SQL Injection Prevention', () => {
|
||||
describe('Authentication Endpoints', () => {
|
||||
it('should prevent SQL injection in login email field', async () => {
|
||||
const maliciousEmail = "admin@example.com'; DROP TABLE users; --";
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: maliciousEmail,
|
||||
password: 'password123'
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(response.body.code).toBe('INVALID_CREDENTIALS');
|
||||
|
||||
// Verify users table still exists by checking our test user
|
||||
const user = await User.findById(testUser.id);
|
||||
expect(user).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should prevent SQL injection in registration email field', async () => {
|
||||
const maliciousEmail = "test@example.com'; INSERT INTO users (email, password_hash) VALUES ('hacker@evil.com', 'hash'); --";
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: maliciousEmail,
|
||||
password: 'TestPassword123!'
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.code).toBe('REGISTRATION_FAILED');
|
||||
|
||||
// Verify no unauthorized user was created
|
||||
const hackerUser = await User.findByEmail('hacker@evil.com');
|
||||
expect(hackerUser).toBeNull();
|
||||
});
|
||||
|
||||
it('should prevent SQL injection in password reset email field', async () => {
|
||||
const maliciousEmail = "test@example.com'; UPDATE users SET is_verified = true WHERE email = 'unverified@example.com'; --";
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/forgot-password')
|
||||
.send({
|
||||
email: maliciousEmail
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// Should return success message regardless (security feature)
|
||||
expect(response.body.message).toContain('password reset link has been sent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bookmark Endpoints', () => {
|
||||
it('should prevent SQL injection in bookmark search', async () => {
|
||||
// Create a test bookmark first
|
||||
await Bookmark.create(testUser.id, {
|
||||
title: 'Secret Bookmark',
|
||||
url: 'https://secret.com',
|
||||
folder: 'Private'
|
||||
});
|
||||
|
||||
const maliciousSearch = "test'; DROP TABLE bookmarks; --";
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/bookmarks?search=${encodeURIComponent(maliciousSearch)}`)
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('bookmarks');
|
||||
expect(response.body).toHaveProperty('pagination');
|
||||
|
||||
// Verify bookmarks table still exists
|
||||
const bookmarks = await Bookmark.findByUserId(testUser.id);
|
||||
expect(bookmarks.bookmarks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should prevent SQL injection in folder filter', async () => {
|
||||
const maliciousFolder = "Work'; DELETE FROM bookmarks WHERE user_id = '" + testUser.id + "'; --";
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/bookmarks?folder=${encodeURIComponent(maliciousFolder)}`)
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('bookmarks');
|
||||
|
||||
// Verify bookmarks weren't deleted
|
||||
const bookmarks = await Bookmark.findByUserId(testUser.id);
|
||||
expect(bookmarks.bookmarks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should prevent SQL injection in bookmark creation', async () => {
|
||||
const maliciousBookmark = {
|
||||
title: "Test'; DROP TABLE bookmarks; --",
|
||||
url: 'https://malicious.com',
|
||||
folder: "Folder'; UPDATE users SET email = 'hacked@evil.com' WHERE id = '" + testUser.id + "'; --"
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.send(maliciousBookmark)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.bookmark.title).toBe("Test'; DROP TABLE bookmarks; --");
|
||||
|
||||
// Verify user email wasn't changed
|
||||
const user = await User.findById(testUser.id);
|
||||
expect(user.email).toBe('security-test@example.com');
|
||||
|
||||
// Verify bookmarks table still exists
|
||||
const bookmarks = await Bookmark.findByUserId(testUser.id);
|
||||
expect(bookmarks.bookmarks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Profile Endpoints', () => {
|
||||
it('should prevent SQL injection in profile update', async () => {
|
||||
const maliciousEmail = "hacker@evil.com'; UPDATE users SET password_hash = 'hacked' WHERE email = 'security-test@example.com'; --";
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/user/profile')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.send({
|
||||
email: maliciousEmail
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.code).toBe('INVALID_EMAIL');
|
||||
|
||||
// Verify user data wasn't compromised
|
||||
const user = await User.findById(testUser.id);
|
||||
expect(user.email).toBe('security-test@example.com');
|
||||
expect(user.password_hash).not.toBe('hacked');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSS Prevention', () => {
|
||||
describe('Input Sanitization', () => {
|
||||
it('should handle XSS attempts in bookmark title', async () => {
|
||||
const xssPayload = '<script>alert("XSS")</script>';
|
||||
const bookmarkData = {
|
||||
title: xssPayload,
|
||||
url: 'https://xss-test.com',
|
||||
folder: 'XSS Test'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.send(bookmarkData)
|
||||
.expect(201);
|
||||
|
||||
// The title should be stored as-is (backend doesn't sanitize HTML)
|
||||
// Frontend should handle XSS prevention during rendering
|
||||
expect(response.body.bookmark.title).toBe(xssPayload);
|
||||
|
||||
// Verify it's stored correctly in database
|
||||
const bookmark = await Bookmark.findByIdAndUserId(response.body.bookmark.id, testUser.id);
|
||||
expect(bookmark.title).toBe(xssPayload);
|
||||
});
|
||||
|
||||
it('should handle XSS attempts in bookmark URL', async () => {
|
||||
const xssUrl = 'javascript:alert("XSS")';
|
||||
const bookmarkData = {
|
||||
title: 'XSS URL Test',
|
||||
url: xssUrl,
|
||||
folder: 'XSS Test'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.send(bookmarkData)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.code).toBe('VALIDATION_ERROR');
|
||||
expect(response.body.error).toContain('Invalid URL format');
|
||||
});
|
||||
|
||||
it('should handle XSS attempts in search parameters', async () => {
|
||||
const xssSearch = '<script>alert("XSS")</script>';
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/bookmarks?search=${encodeURIComponent(xssSearch)}`)
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('bookmarks');
|
||||
expect(response.body).toHaveProperty('pagination');
|
||||
|
||||
// Search should work normally, returning empty results
|
||||
expect(response.body.bookmarks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Headers', () => {
|
||||
it('should include security headers in responses', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/bookmarks')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
// Check for security headers (set by helmet middleware)
|
||||
expect(response.headers).toHaveProperty('x-content-type-options');
|
||||
expect(response.headers).toHaveProperty('x-frame-options');
|
||||
expect(response.headers).toHaveProperty('x-xss-protection');
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication Security', () => {
|
||||
describe('Token Security', () => {
|
||||
it('should reject requests with invalid JWT tokens', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/bookmarks')
|
||||
.set('Cookie', 'authToken=invalid.jwt.token')
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should reject requests with expired tokens', async () => {
|
||||
// Create a token with very short expiration
|
||||
const jwt = require('jsonwebtoken');
|
||||
const expiredToken = jwt.sign(
|
||||
{ userId: testUser.id, email: testUser.email },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1ms' } // Expires immediately
|
||||
);
|
||||
|
||||
// Wait a moment to ensure token is expired
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/bookmarks')
|
||||
.set('Cookie', `authToken=${expiredToken}`)
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should reject requests with malformed tokens', async () => {
|
||||
const malformedTokens = [
|
||||
'not.a.jwt',
|
||||
'header.payload', // Missing signature
|
||||
'header.payload.signature.extra', // Too many parts
|
||||
'', // Empty token
|
||||
'Bearer token-without-bearer-prefix'
|
||||
];
|
||||
|
||||
for (const token of malformedTokens) {
|
||||
const response = await request(app)
|
||||
.get('/api/bookmarks')
|
||||
.set('Cookie', `authToken=${token}`)
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Security', () => {
|
||||
it('should set secure cookie attributes in production', async () => {
|
||||
// Temporarily set NODE_ENV to production
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: 'security-test@example.com',
|
||||
password: 'TestPassword123!'
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const cookies = response.headers['set-cookie'];
|
||||
const authCookie = cookies.find(cookie => cookie.includes('authToken'));
|
||||
|
||||
expect(authCookie).toContain('HttpOnly');
|
||||
expect(authCookie).toContain('SameSite=Strict');
|
||||
expect(authCookie).toContain('Secure'); // Should be secure in production
|
||||
|
||||
// Restore original environment
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it('should clear cookies on logout', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
const cookies = response.headers['set-cookie'];
|
||||
const clearedCookie = cookies.find(cookie => cookie.includes('authToken'));
|
||||
|
||||
expect(clearedCookie).toContain('authToken=;');
|
||||
expect(clearedCookie).toContain('HttpOnly');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Security', () => {
|
||||
it('should not expose password hashes in API responses', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/profile')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.user).not.toHaveProperty('password_hash');
|
||||
expect(response.body.user).not.toHaveProperty('verification_token');
|
||||
expect(response.body.user).not.toHaveProperty('reset_token');
|
||||
});
|
||||
|
||||
it('should enforce password strength requirements', async () => {
|
||||
const weakPasswords = [
|
||||
'weak',
|
||||
'12345678',
|
||||
'password',
|
||||
'Password',
|
||||
'Password123',
|
||||
'Password!'
|
||||
];
|
||||
|
||||
for (const password of weakPasswords) {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: `weak-${Date.now()}@example.com`,
|
||||
password: password
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.code).toBe('REGISTRATION_FAILED');
|
||||
}
|
||||
});
|
||||
|
||||
it('should hash passwords before storage', async () => {
|
||||
const testEmail = `hash-test-${Date.now()}@example.com`;
|
||||
const testPassword = 'TestPassword123!';
|
||||
|
||||
await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: testEmail,
|
||||
password: testPassword
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const user = await User.findByEmail(testEmail);
|
||||
expect(user.password_hash).toBeDefined();
|
||||
expect(user.password_hash).not.toBe(testPassword);
|
||||
expect(user.password_hash.length).toBeGreaterThan(50); // bcrypt hashes are long
|
||||
|
||||
// Cleanup
|
||||
await user.delete();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting Security', () => {
|
||||
it('should enforce rate limits on sensitive endpoints', async () => {
|
||||
const requests = [];
|
||||
|
||||
// Make multiple rapid requests to trigger rate limiting
|
||||
for (let i = 0; i < 6; i++) {
|
||||
requests.push(
|
||||
request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'wrongpassword'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// Should have at least one rate-limited response
|
||||
const rateLimitedResponse = responses.find(res => res.status === 429);
|
||||
expect(rateLimitedResponse).toBeDefined();
|
||||
expect(rateLimitedResponse.body.code).toBe('RATE_LIMIT_EXCEEDED');
|
||||
}, 10000);
|
||||
|
||||
it('should include rate limit headers', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword'
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
// Rate limiting middleware should add these headers
|
||||
expect(response.headers).toHaveProperty('x-ratelimit-limit');
|
||||
expect(response.headers).toHaveProperty('x-ratelimit-remaining');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Validation Security', () => {
|
||||
it('should validate and sanitize input lengths', async () => {
|
||||
const longString = 'a'.repeat(10000);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.send({
|
||||
title: longString,
|
||||
url: 'https://example.com',
|
||||
folder: longString
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('should validate email formats strictly', async () => {
|
||||
const invalidEmails = [
|
||||
'not-an-email',
|
||||
'@example.com',
|
||||
'user@',
|
||||
'user..name@example.com',
|
||||
'user name@example.com',
|
||||
'user@example',
|
||||
'user@.example.com'
|
||||
];
|
||||
|
||||
for (const email of invalidEmails) {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: email,
|
||||
password: 'TestPassword123!'
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.code).toBe('REGISTRATION_FAILED');
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate URL formats in bookmarks', async () => {
|
||||
const invalidUrls = [
|
||||
'not-a-url',
|
||||
'ftp://example.com', // Only HTTP/HTTPS should be allowed
|
||||
'javascript:alert("xss")',
|
||||
'data:text/html,<script>alert("xss")</script>',
|
||||
'file:///etc/passwd'
|
||||
];
|
||||
|
||||
for (const url of invalidUrls) {
|
||||
const response = await request(app)
|
||||
.post('/api/bookmarks')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.send({
|
||||
title: 'Test Bookmark',
|
||||
url: url
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.code).toBe('VALIDATION_ERROR');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Information Disclosure', () => {
|
||||
it('should not expose sensitive information in error messages', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: 'security-test@example.com',
|
||||
password: 'wrongpassword'
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
// Should not reveal whether email exists or password is wrong
|
||||
expect(response.body.error).toBe('Invalid email or password');
|
||||
expect(response.body.error).not.toContain('password');
|
||||
expect(response.body.error).not.toContain('email');
|
||||
expect(response.body.error).not.toContain('user');
|
||||
});
|
||||
|
||||
it('should not expose database errors to clients', async () => {
|
||||
// This test would require mocking database to throw an error
|
||||
// For now, we'll test that 500 errors don't expose internal details
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/bookmarks/invalid-uuid-format')
|
||||
.set('Cookie', `authToken=${authToken}`)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).not.toContain('database');
|
||||
expect(response.body.error).not.toContain('query');
|
||||
expect(response.body.error).not.toContain('SQL');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user