Fix super-sync server auth and snapshot safety

This commit is contained in:
Johannes Millan 2025-12-09 20:01:57 +01:00
parent 4012adc64a
commit c45a1ca611
5 changed files with 117 additions and 23 deletions

View file

@ -23,6 +23,10 @@ const getTransporter = async (): Promise<nodemailer.Transporter> => {
});
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: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Welcome to SuperSync!</h2>

View file

@ -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 {

View file

@ -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

View file

@ -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);
});
});

View file

@ -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();