mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-03 13:02:26 +00:00
Fix super-sync server auth and snapshot safety
This commit is contained in:
parent
4012adc64a
commit
c45a1ca611
5 changed files with 117 additions and 23 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
packages/super-sync-server/tests/email.spec.ts
Normal file
27
packages/super-sync-server/tests/email.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue