feat(syncServer): add rate limiting, enhance user registration with stronger password validation and add verification token expiration

This commit is contained in:
Johannes Millan 2025-11-27 20:30:53 +01:00
parent ec2de9c533
commit 87f459f5e6
6 changed files with 106 additions and 54 deletions

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.3.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",

View file

@ -6,7 +6,13 @@ import { Logger } from './logger';
// Zod Schemas
const RegisterSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters long'),
password: z
.string()
.min(12, 'Password must be at least 12 characters long')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/\d/, 'Password must contain at least one number')
.regex(/[\W_]/, 'Password must contain at least one special character'),
});
const LoginSchema = z.object({
@ -40,9 +46,7 @@ export const apiRoutes = async (fastify: FastifyInstance): Promise<void> => {
const { email, password } = parseResult.data;
const result = await registerUser(email, password);
return reply
.status(201)
.send({ message: 'User registered. Please verify your email.', ...result });
return reply.status(201).send(result);
} catch (err) {
Logger.error(`Registration error: ${errorMessage(err)}`);
// Generic error message to avoid leaking implementation details (e.g. specific DB errors)

View file

@ -11,67 +11,57 @@ const getJwtSecret = (): string => {
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET environment variable is required in production');
}
Logger.warn('JWT_SECRET not set - using insecure default for development only');
return 'dev-only-insecure-secret';
Logger.warn('JWT_SECRET not set - using random secret for development');
return randomBytes(64).toString('hex');
}
return secret;
};
const JWT_SECRET = getJwtSecret();
// Simple email validation regex
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = (email: string): boolean => EMAIL_REGEX.test(email);
export const registerUser = async (
email: string,
password: string,
): Promise<{ id: number | bigint; email: string }> => {
// Validate email format
if (!isValidEmail(email)) {
throw new Error('Invalid email format');
}
// Validate password length
if (password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
): Promise<{ message: string }> => {
// Password strength validation is handled by Zod in api.ts
const db = getDb();
// Check if user exists
const existingUser = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (existingUser) {
throw new Error('User already exists');
}
const passwordHash = bcrypt.hashSync(password, 10);
const passwordHash = bcrypt.hashSync(password, 12);
const verificationToken = randomBytes(32).toString('hex');
const expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
const info = db
.prepare(
`
INSERT INTO users (email, password_hash, verification_token)
VALUES (?, ?, ?)
`,
)
.run(email, passwordHash, verificationToken);
try {
const info = db
.prepare(
`
INSERT INTO users (email, password_hash, verification_token, verification_token_expires_at)
VALUES (?, ?, ?, ?)
`,
)
.run(email, passwordHash, verificationToken, expiresAt);
Logger.info(`User registered: ${email} (ID: ${info.lastInsertRowid})`);
Logger.info(`User registered: ${email} (ID: ${info.lastInsertRowid})`);
// Send verification email asynchronously
const emailSent = await sendVerificationEmail(email, verificationToken);
if (!emailSent) {
// Clean up the newly created account to prevent unusable, un-verifiable entries
db.prepare('DELETE FROM users WHERE id = ?').run(info.lastInsertRowid);
throw new Error('Failed to send verification email. Please try again later.');
// Send verification email asynchronously
const emailSent = await sendVerificationEmail(email, verificationToken);
if (!emailSent) {
// Clean up the newly created account to prevent unusable, un-verifiable entries
db.prepare('DELETE FROM users WHERE id = ?').run(info.lastInsertRowid);
throw new Error('Failed to send verification email. Please try again later.');
}
} catch (err: any) {
// If unique constraint violation (user exists), we swallow the error
// to prevent email enumeration.
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
Logger.info(`Registration attempt for existing email: ${email}`);
// In a real system, we might want to send a "You already have an account" email here
} else {
throw err;
}
}
return {
id: info.lastInsertRowid,
email,
// verificationToken, // Don't return token in response for security, user must check email
message: 'Registration successful. Please check your email to verify your account.',
};
};
@ -86,8 +76,15 @@ export const verifyEmail = (token: string): boolean => {
throw new Error('Invalid verification token');
}
if (
user.verification_token_expires_at &&
user.verification_token_expires_at < Date.now()
) {
throw new Error('Verification token has expired');
}
db.prepare(
'UPDATE users SET is_verified = 1, verification_token = NULL WHERE id = ?',
'UPDATE users SET is_verified = 1, verification_token = NULL, verification_token_expires_at = NULL WHERE id = ?',
).run(user.id);
Logger.info(`User verified: ${user.email}`);
@ -104,11 +101,15 @@ export const loginUser = (
| User
| undefined;
if (!user) {
throw new Error('Invalid credentials');
}
// Timing attack mitigation: always perform a comparison
// Use a dummy hash so the comparison takes roughly the same time
// $2a$12$ is bcrypt prefix for 12 rounds
const dummyHash = '$2a$12$e0MyzXyjpJS7dAC5B5S3.O/p.u.m.u.m.u.m.u.m.u.m.u.m.u.m.';
const hashToCompare = user ? user.password_hash : dummyHash;
if (!bcrypt.compareSync(password, user.password_hash)) {
const isMatch = bcrypt.compareSync(password, hashToCompare);
if (!user || !isMatch) {
throw new Error('Invalid credentials');
}
@ -117,7 +118,7 @@ export const loginUser = (
}
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, {
expiresIn: '30d',
expiresIn: '7d',
});
return { token, user: { id: user.id, email: user.email } };

View file

@ -9,6 +9,7 @@ export interface User {
password_hash: string;
is_verified: number; // 0 or 1
verification_token: string | null;
verification_token_expires_at: number | null; // Unix timestamp
created_at: string;
}
@ -32,10 +33,22 @@ export const initDb = (dataDir: string) => {
password_hash TEXT NOT NULL,
is_verified INTEGER DEFAULT 0,
verification_token TEXT,
verification_token_expires_at INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Migration: Check if verification_token_expires_at exists
const columns = db.pragma('table_info(users)') as { name: string }[];
const hasExpiresAt = columns.some(
(col) => col.name === 'verification_token_expires_at',
);
if (!hasExpiresAt) {
Logger.info('Migrating database: adding verification_token_expires_at column');
db.exec('ALTER TABLE users ADD COLUMN verification_token_expires_at INTEGER');
}
Logger.info(`Database initialized at ${dbPath}`);
};

View file

@ -6,6 +6,16 @@ import { Logger } from './logger';
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : 'Unknown error';
// Basic HTML escape
const escapeHtml = (unsafe: string): string => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
interface VerifyEmailQuery {
token?: string;
}
@ -41,7 +51,9 @@ export async function pageRoutes(fastify: FastifyInstance) {
`);
} catch (err) {
Logger.error(`Verification error: ${errorMessage(err)}`);
return reply.status(400).send(`Verification failed: ${errorMessage(err)}`);
// Escape the error message to prevent XSS
const safeError = escapeHtml(errorMessage(err));
return reply.status(400).send(`Verification failed: ${safeError}`);
}
});
}

View file

@ -2,6 +2,7 @@ import { v2 as webdav } from 'webdav-server';
import * as fs from 'fs';
import Fastify, { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import * as path from 'path';
import { loadConfigFromEnv, ServerConfig } from './config';
@ -131,6 +132,14 @@ export const createServer = (
logger: false, // We use our own logger
});
// Rate Limiting (prevent brute force)
// 100 requests per 15 minutes globally is a safe default
// We can tighten this for specific routes if needed
await fastifyServer.register(rateLimit, {
max: 100,
timeWindow: '15 minutes',
});
// CORS Configuration
if (fullConfig.cors.enabled) {
await fastifyServer.register(cors, {
@ -228,6 +237,18 @@ export const createServer = (
const normalizedUrl = originalUrl.startsWith('/')
? originalUrl
: `/${originalUrl}`;
// Path Traversal Protection
// Ensure path does not contain ".." segments
if (
normalizedUrl.includes('/../') ||
normalizedUrl.endsWith('/..') ||
normalizedUrl.includes('\\..\\')
) {
reply.status(400).send({ error: 'Invalid path: Path traversal not allowed' });
return;
}
const userScopedUrl = `/${userSegment}${normalizedUrl}`.replace(/\/{2,}/g, '/');
req.raw.url = userScopedUrl;