539 lines
22 KiB
JavaScript
539 lines
22 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
}); |