diff --git a/packages/super-sync-server/src/email.ts b/packages/super-sync-server/src/email.ts index 0a9dd3cc7f..9d46704664 100644 --- a/packages/super-sync-server/src/email.ts +++ b/packages/super-sync-server/src/email.ts @@ -23,6 +23,10 @@ const getTransporter = async (): Promise => { }); Logger.info(`SMTP configured: ${config.smtp.host}:${config.smtp.port}`); } else { + if (process.env.NODE_ENV === 'production') { + throw new Error('SMTP configuration is required in production environments'); + } + // Fallback to Ethereal for development if no SMTP config Logger.warn('No SMTP configuration found. Using Ethereal Email for testing.'); const testAccount = await nodemailer.createTestAccount(); @@ -56,7 +60,9 @@ export const sendVerificationEmail = async ( from, to, subject: 'Verify your SuperSync account', - text: `Please verify your account by clicking the following link: ${verificationLink}\n\nIf clicking the link doesn't work, copy and paste it into your browser.`, + text: + `Please verify your account by clicking the following link: ${verificationLink}\n\n` + + `If clicking the link doesn't work, copy and paste it into your browser.`, html: `

Welcome to SuperSync!

diff --git a/packages/super-sync-server/src/sync/sync.service.ts b/packages/super-sync-server/src/sync/sync.service.ts index fbfa0be2d4..e835c2f204 100644 --- a/packages/super-sync-server/src/sync/sync.service.ts +++ b/packages/super-sync-server/src/sync/sync.service.ts @@ -64,6 +64,12 @@ const MAX_OPS_FOR_SNAPSHOT = 100000; */ const MAX_SNAPSHOT_SIZE_BYTES = 50 * 1024 * 1024; +/** + * Maximum decompressed snapshot size in bytes (100MB). + * Prevents zip bombs from exhausting memory when reading cached snapshots. + */ +const MAX_SNAPSHOT_DECOMPRESSED_BYTES = 100 * 1024 * 1024; + interface PreparedStatements { insertOp: Database.Statement; getNextSeq: Database.Statement; @@ -674,14 +680,25 @@ export class SyncService { if (!row?.snapshot_data) return null; - // Decompress snapshot - const decompressed = zlib.gunzipSync(row.snapshot_data).toString('utf-8'); - return { - state: JSON.parse(decompressed), - serverSeq: row.last_snapshot_seq ?? 0, - generatedAt: row.snapshot_at ?? 0, - schemaVersion: row.snapshot_schema_version ?? 1, - }; + try { + // Decompress snapshot with an upper bound to prevent zip bombs + const decompressed = zlib + .gunzipSync(row.snapshot_data, { + maxOutputLength: MAX_SNAPSHOT_DECOMPRESSED_BYTES, + }) + .toString('utf-8'); + return { + state: JSON.parse(decompressed), + serverSeq: row.last_snapshot_seq ?? 0, + generatedAt: row.snapshot_at ?? 0, + schemaVersion: row.snapshot_schema_version ?? 1, + }; + } catch (err) { + Logger.error( + `[user:${userId}] Failed to decompress cached snapshot: ${(err as Error).message}`, + ); + return null; + } } cacheSnapshot(userId: number, state: unknown, serverSeq: number): void { diff --git a/packages/super-sync-server/tests/auth-flows.spec.ts b/packages/super-sync-server/tests/auth-flows.spec.ts index 08633e3bd8..e52e08c6d0 100644 --- a/packages/super-sync-server/tests/auth-flows.spec.ts +++ b/packages/super-sync-server/tests/auth-flows.spec.ts @@ -57,11 +57,12 @@ describe('Authentication Flows', () => { it('should reject login when account is locked', async () => { const db = getDb(); + const fifteenMinutesMs = 15 * 60 * 1000; // Lock the account manually db.prepare( 'UPDATE users SET locked_until = ?, failed_login_attempts = 5 WHERE email = ?', ).run( - Date.now() + 15 * 60 * 1000, // 15 minutes from now + Date.now() + fifteenMinutesMs, // 15 minutes from now email, ); @@ -193,10 +194,11 @@ describe('Authentication Flows', () => { await registerUser('expired@test.com', 'SecurePass123!'); const db = getDb(); + const oneHourMs = 60 * 60 * 1000; // Set token to expired (1 hour ago) db.prepare( 'UPDATE users SET verification_token_expires_at = ? WHERE email = ?', - ).run(Date.now() - 60 * 60 * 1000, 'expired@test.com'); + ).run(Date.now() - oneHourMs, 'expired@test.com'); const user = db .prepare('SELECT * FROM users WHERE email = ?') @@ -249,9 +251,6 @@ describe('Authentication Flows', () => { await registerUser('stuck@test.com', 'SecurePass123!'); const db = getDb(); - const user = db - .prepare('SELECT * FROM users WHERE email = ?') - .get('stuck@test.com') as User; const expiredToken = 'expired-token'; // Simulate prior resend and expiry diff --git a/packages/super-sync-server/tests/email.spec.ts b/packages/super-sync-server/tests/email.spec.ts new file mode 100644 index 0000000000..6ca1fb8766 --- /dev/null +++ b/packages/super-sync-server/tests/email.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; + +const originalEnv = { ...process.env }; + +const resetEnv = (): void => { + process.env = { ...originalEnv }; +}; + +describe('Email transport configuration', () => { + beforeEach(() => { + resetEnv(); + vi.resetModules(); + }); + + afterEach(() => { + resetEnv(); + }); + + it('should fail gracefully in production without SMTP configuration', async () => { + process.env.NODE_ENV = 'production'; + process.env.PUBLIC_URL = 'https://example.com'; + + const { sendVerificationEmail } = await import('../src/email'); + const result = await sendVerificationEmail('user@test.com', 'token'); + expect(result).toBe(false); + }); +}); diff --git a/packages/super-sync-server/tests/sync-operations.spec.ts b/packages/super-sync-server/tests/sync-operations.spec.ts index 4a608456cc..2622caba74 100644 --- a/packages/super-sync-server/tests/sync-operations.spec.ts +++ b/packages/super-sync-server/tests/sync-operations.spec.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { initDb, getDb } from '../src/db'; import { initSyncService, getSyncService } from '../src/sync/sync.service'; import { Operation, MS_PER_DAY } from '../src/sync/sync.types'; import { uuidv7 } from 'uuidv7'; +import * as zlib from 'zlib'; describe('Sync Operations', () => { const userId = 1; @@ -141,7 +142,7 @@ describe('Sync Operations', () => { const service = getSyncService(); service.uploadOps(userId, clientId, [createOp('task-1', 'CRT')]); - const snapshot1 = service.generateSnapshot(userId); + service.generateSnapshot(userId); // Add new operation service.uploadOps(userId, clientId, [createOp('task-2', 'CRT')]); @@ -192,6 +193,22 @@ describe('Sync Operations', () => { const snapshot = service.generateSnapshot(userId); expect(snapshot).toBeDefined(); }); + + it('should discard cached snapshot if decompression exceeds limit', () => { + const service = getSyncService(); + + service.uploadOps(userId, clientId, [createOp('task-1', 'CRT')]); + service.generateSnapshot(userId); + + const gunzipSpy = vi.spyOn(zlib, 'gunzipSync').mockImplementation(() => { + throw new RangeError('maxOutputLength exceeded'); + }); + + const cached = service.getCachedSnapshot(userId); + expect(cached).toBeNull(); + + gunzipSpy.mockRestore(); + }); }); describe('Rate Limiting', () => { @@ -307,16 +324,19 @@ describe('Sync Operations', () => { const db = getDb(); // Create a tombstone manually with expired time + const hundredDaysMs = MS_PER_DAY * 100; + const oneDayMs = MS_PER_DAY; db.prepare( `INSERT INTO tombstones (user_id, entity_type, entity_id, deleted_at, deleted_by_op_id, expires_at) VALUES (?, 'TASK', 'old-task', ?, 'op-123', ?)`, - ).run(userId, Date.now() - MS_PER_DAY * 100, Date.now() - MS_PER_DAY); // Expired yesterday + ).run(userId, Date.now() - hundredDaysMs, Date.now() - oneDayMs); // Expired yesterday // Also create a non-expired tombstone + const ninetyDaysMs = MS_PER_DAY * 90; db.prepare( `INSERT INTO tombstones (user_id, entity_type, entity_id, deleted_at, deleted_by_op_id, expires_at) VALUES (?, 'TASK', 'new-task', ?, 'op-456', ?)`, - ).run(userId, Date.now(), Date.now() + MS_PER_DAY * 90); // Expires in 90 days + ).run(userId, Date.now(), Date.now() + ninetyDaysMs); // Expires in 90 days // Cleanup should delete the expired one const deleted = service.deleteExpiredTombstones(); @@ -340,13 +360,15 @@ describe('Sync Operations', () => { ]); // Manually set one operation to be "old" (received 100 days ago) + const hundredDaysMs = MS_PER_DAY * 100; db.prepare('UPDATE operations SET received_at = ? WHERE server_seq = ?').run( - Date.now() - MS_PER_DAY * 100, + Date.now() - hundredDaysMs, 1, ); // Delete operations older than 90 days - const cutoff = Date.now() - MS_PER_DAY * 90; + const ninetyDaysMs = MS_PER_DAY * 90; + const cutoff = Date.now() - ninetyDaysMs; const deleted = service.deleteOldSyncedOpsForAllUsers(cutoff); expect(deleted).toBe(1); @@ -365,13 +387,15 @@ describe('Sync Operations', () => { service.uploadOps(userId, clientId, [createOp('task-1', 'CRT')]); // Manually set device to stale (not seen in 60 days) + const sixtyDaysMs = MS_PER_DAY * 60; db.prepare('UPDATE sync_devices SET last_seen_at = ? WHERE client_id = ?').run( - Date.now() - MS_PER_DAY * 60, + Date.now() - sixtyDaysMs, clientId, ); // Delete devices not seen in 50 days - const cutoff = Date.now() - MS_PER_DAY * 50; + const fiftyDaysMs = MS_PER_DAY * 50; + const cutoff = Date.now() - fiftyDaysMs; const deleted = service.deleteStaleDevices(cutoff); expect(deleted).toBe(1); @@ -384,7 +408,8 @@ describe('Sync Operations', () => { service.uploadOps(userId, clientId, [createOp('task-1', 'CRT')]); // Try to delete devices not seen in 50 days - const cutoff = Date.now() - MS_PER_DAY * 50; + const fiftyDaysMs = MS_PER_DAY * 50; + const cutoff = Date.now() - fiftyDaysMs; const deleted = service.deleteStaleDevices(cutoff); // Should not delete anything (device was just seen) @@ -440,6 +465,26 @@ describe('Sync Operations', () => { }); }); + describe('Database Constraints', () => { + it('should cascade delete operations when user is removed', () => { + const service = getSyncService(); + + service.uploadOps(userId, clientId, [ + createOp('task-1', 'CRT'), + createOp('task-2', 'CRT'), + ]); + + const db = getDb(); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + + const remaining = db + .prepare('SELECT COUNT(*) as count FROM operations WHERE user_id = ?') + .get(userId) as { count: number }; + + expect(remaining.count).toBe(0); + }); + }); + describe('Device Ownership', () => { it('should track device ownership after upload', () => { const service = getSyncService();