Spaces:
Sleeping
Sleeping
File size: 7,723 Bytes
1f5ea39 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | /**
* Security integration tests.
* Covers SEC-001 to SEC-015.
*
* Notes:
* - SSRF tests (SEC-001 to SEC-004) are unit-level tests on ssrfGuard β see tests/unit/utils/ssrfGuard.test.ts
* - SEC-015 (MFA backup codes) is covered in auth.test.ts
* - These tests focus on HTTP-level security: headers, auth, injection protection, etc.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import path from 'path';
import fs from 'fs';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip } from '../helpers/factories';
import { authCookie, authHeader, generateToken } from '../helpers/auth';
let nestApp: INestApplication;
let app: Application;
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
beforeEach(() => {
resetTestDb(testDb);
resetRateLimits(nestApp);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
afterAll(async () => {
await nestApp.close();
fs.rmSync(uploadsDir, { recursive: true, force: true });
testDb.close();
});
describe('Authentication security', () => {
it('SEC-007 β invalid JWT in Authorization Bearer header is rejected', async () => {
const { user } = createUser(testDb);
const token = generateToken(user.id);
// The file download endpoint accepts bearer auth
// Other endpoints use cookie auth β but /api/auth/me works with cookie auth
// Test that a forged/invalid JWT is rejected
const res = await request(app)
.get('/api/auth/me')
.set('Authorization', 'Bearer invalid.token.here');
// Should return 401 (auth fails)
expect(res.status).toBe(401);
});
it('unauthenticated request to protected endpoint returns 401', async () => {
const res = await request(app).get('/api/trips');
expect(res.status).toBe(401);
});
it('expired/invalid JWT cookie returns 401', async () => {
const res = await request(app)
.get('/api/trips')
.set('Cookie', 'trek_session=invalid.jwt.token');
expect(res.status).toBe(401);
});
});
describe('Security headers', () => {
it('SEC-011 β Helmet sets X-Content-Type-Options header', async () => {
const res = await request(app).get('/api/health');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('SEC-011 β Helmet sets X-Frame-Options header', async () => {
const res = await request(app).get('/api/health');
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
});
});
describe('API key encryption', () => {
it('SEC-008 β encrypted API keys are stored with enc:v1: prefix', async () => {
const { user } = createUser(testDb);
await request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'test-api-key-12345' });
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
});
it('SEC-008 β GET /api/auth/me does not return plaintext API key', async () => {
const { user } = createUser(testDb);
await request(app)
.put('/api/auth/me/api-keys')
.set('Cookie', authCookie(user.id))
.send({ openweather_api_key: 'secret-key' });
const me = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(me.body.user.openweather_api_key).not.toBe('secret-key');
});
});
describe('MFA secret protection', () => {
it('SEC-009 β GET /api/auth/me does not expose mfa_secret', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/auth/me')
.set('Cookie', authCookie(user.id));
expect(res.body.user.mfa_secret).toBeUndefined();
expect(res.body.user.password_hash).toBeUndefined();
});
});
describe('Request body size limit', () => {
it('SEC-013 β oversized JSON body is rejected', async () => {
// Send a large body (2MB+) to exceed the default limit
const bigData = { data: 'x'.repeat(2 * 1024 * 1024) };
const res = await request(app)
.post('/api/auth/login')
.send(bigData);
// body-parser rejects oversized payloads with 413
expect(res.status).toBe(413);
});
});
describe('File download path traversal', () => {
it('SEC-005 β path traversal in file download is blocked', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await request(app)
.post(`/api/trips/${trip.id}/files`)
.set('Cookie', authCookie(user.id))
.attach('file', FIXTURE_IMG);
expect(upload.status).toBe(201);
const fileId = upload.body.file.id;
testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId);
const res = await request(app)
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
.set(authHeader(user.id));
// resolveFilePath strips traversal via path.basename; normalized file does not exist in uploads
expect(res.status).not.toBe(200);
});
});
|