From 8de85b7eae38b9af76154e40cdeff53d771f6e92 Mon Sep 17 00:00:00 2001 From: Rui Costa Date: Mon, 15 Sep 2025 13:35:09 -0400 Subject: [PATCH] feat(websocket): implement real-time sync with HMAC authentication and fix folder moves - Add complete WebSocket implementation for real-time note/folder synchronization - JWT authentication with 30-second timeout - Rate limiting (300 msg/min) and connection limits (20/user) - Connection management with automatic cleanup - Nonce-based replay attack prevention - Fix critical folder move sync issue - Add 'folderId' to allowed fields in note updates - Enable real-time sync of note folder changes across devices - Add comprehensive error logging for debugging - Enhance security infrastructure - Add security headers middleware (CSP, HSTS, XSS protection) - Implement enhanced rate limiting middleware - Fix TypeScript compilation issues - Resolve Map iterator compatibility with ES2022 target - Fix postgres module import issues - Add downlevelIteration support for future compatibility - Update comprehensive documentation - Add WEBSOCKET_INTEGRATION.md with complete protocol specification - Update README.md with current project structure and WebSocket features - Update SECURITY.md with latest security features and recommendations --- .env.example | 194 ++++++- CHANGELOG.md | 22 + README.md | 88 ++- SECURITY.md | 176 ++++++ WEBSOCKET_INTEGRATION.md | 534 ++++++++++++++++++ eslint.config.js | 13 +- package.json | 2 + pnpm-lock.yaml | 27 + src/db/index.ts | 4 +- src/lib/validation.ts | 51 +- src/middleware/rate-limit.ts | 116 ++++ src/middleware/security.ts | 38 ++ src/routes/folders.ts | 39 +- src/routes/notes.ts | 10 +- src/server.ts | 157 ++++- src/websocket/auth/handler.ts | 282 +++++++++ src/websocket/handlers/base.ts | 111 ++++ src/websocket/handlers/folders.ts | 45 ++ src/websocket/handlers/notes.ts | 177 ++++++ src/websocket/index.ts | 266 +++++++++ .../middleware/connection-manager.ts | 110 ++++ src/websocket/middleware/rate-limiter.ts | 37 ++ src/websocket/types.ts | 55 ++ tsconfig.json | 2 + 24 files changed, 2478 insertions(+), 78 deletions(-) create mode 100644 SECURITY.md create mode 100644 WEBSOCKET_INTEGRATION.md create mode 100644 src/middleware/rate-limit.ts create mode 100644 src/middleware/security.ts create mode 100644 src/websocket/auth/handler.ts create mode 100644 src/websocket/handlers/base.ts create mode 100644 src/websocket/handlers/folders.ts create mode 100644 src/websocket/handlers/notes.ts create mode 100644 src/websocket/index.ts create mode 100644 src/websocket/middleware/connection-manager.ts create mode 100644 src/websocket/middleware/rate-limiter.ts create mode 100644 src/websocket/types.ts diff --git a/.env.example b/.env.example index 1a4ab42..4d7883a 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,190 @@ -# LOCAL DEVELOPMENT AND TESTING ENVIRONMENT VARIABLES -# This file is ONLY for local development and testing -# Copy this file to .env and update with your local testing values +# ================================================================ +# TYPELETS API - ENVIRONMENT CONFIGURATION TEMPLATE +# ================================================================ +# This file is for LOCAL DEVELOPMENT AND TESTING ONLY +# Copy this file to `.env` and update with your actual values +# Production deployments use AWS ECS task definitions, not .env files -# Database connection for local testing -# For Docker PostgreSQL (recommended for testing): +# ================================================================ +# REQUIRED CONFIGURATION +# ================================================================ + +# Database Connection (REQUIRED) +# For Docker PostgreSQL (recommended for local development): DATABASE_URL=postgresql://postgres:devpassword@localhost:5432/typelets_local # For local PostgreSQL installation: # DATABASE_URL=postgresql://postgres:your_password@localhost:5432/typelets_local +# For production (example): +# DATABASE_URL=postgresql://username:password@hostname:5432/database?sslmode=require -# Clerk authentication for local testing (get from https://dashboard.clerk.com) +# Clerk Authentication (REQUIRED) +# Get your secret key from: https://dashboard.clerk.com/ +# For development, use test keys (sk_test_...) +# For production, use live keys (sk_live_...) CLERK_SECRET_KEY=sk_test_your_actual_clerk_secret_key_from_dashboard -# CORS configuration - REQUIRED -# Comma-separated list of allowed origins +# CORS Origins (REQUIRED) +# Comma-separated list of allowed origins (no spaces after commas) +# Include all frontend domains that will access this API CORS_ORIGINS=http://localhost:5173,http://localhost:3000 +# Production example: +# CORS_ORIGINS=https://app.typelets.com,https://typelets.com + +# ================================================================ +# SERVER CONFIGURATION +# ================================================================ -# Optional settings (have sensible defaults) +# Server Port PORT=3000 + +# Environment NODE_ENV=development -# File upload configuration -# MAX_FILE_SIZE_MB=50 # Maximum size per file in MB (default: 50) -# MAX_NOTE_SIZE_MB=1024 # Maximum total attachments per note in MB (default: 1024MB = 1GB) +# ================================================================ +# SECURITY CONFIGURATION +# ================================================================ + +# WebSocket Message Authentication +# Generate with: openssl rand -hex 32 +# CRITICAL: Use a strong random string (minimum 32 characters) in production +MESSAGE_AUTH_SECRET=your-very-secure-random-string-here-min-32-chars + +# ================================================================ +# RATE LIMITING CONFIGURATION +# ================================================================ +# Current defaults are development-friendly +# Adjust these values based on your usage patterns + +# HTTP API Rate Limiting +# HTTP_RATE_LIMIT_WINDOW_MS=900000 # 15 minutes in milliseconds +# HTTP_RATE_LIMIT_MAX_REQUESTS=1000 # Max requests per window per user/IP +# HTTP_FILE_RATE_LIMIT_MAX=100 # Max file operations per window + +# WebSocket Rate Limiting +WS_RATE_LIMIT_WINDOW_MS=60000 # Time window: 1 minute (in milliseconds) +WS_RATE_LIMIT_MAX_MESSAGES=300 # Max messages per window per connection +WS_MAX_CONNECTIONS_PER_USER=20 # Max concurrent connections per user +WS_AUTH_TIMEOUT_MS=30000 # Authentication timeout: 30 seconds + +# Production Rate Limiting Recommendations: +# - HTTP: 500-1000 requests per 15 minutes per user +# - WebSocket: 100-300 messages per minute per connection +# - File uploads: 50-100 operations per 15 minutes per user +# - Connections: 10-20 per user depending on multi-device usage + +# ================================================================ +# FILE UPLOAD CONFIGURATION +# ================================================================ + +# File Size Limits +MAX_FILE_SIZE_MB=50 # Maximum size per file (default: 50MB) +# MAX_NOTE_SIZE_MB=1024 # Maximum total attachments per note (default: 1GB) + +# Allowed File Types (handled in code, documented here) +# Images: JPEG, PNG, GIF, WebP +# Documents: PDF, Plain Text, Markdown, JSON, CSV +# Add new types in: src/lib/validation.ts + +# ================================================================ +# BILLING & LIMITS CONFIGURATION +# ================================================================ + +# Free Tier Limits +FREE_TIER_STORAGE_GB=1 # Storage limit for free users (default: 1GB) +FREE_TIER_NOTE_LIMIT=100 # Note count limit for free users (default: 100) + +# Usage tracking for billing analytics +# These limits trigger billing events logged to console +# Upgrade prompts and enforcement handled in frontend + +# ================================================================ +# LOGGING & MONITORING +# ================================================================ + +# Debug Logging +# DEBUG=false # Set to true for verbose logging (not recommended in production) + +# Application Logging +# Structured logs are automatically generated for: +# - Authentication events +# - Rate limiting violations +# - Security events (failed auth, suspicious activity) +# - Billing limit violations +# - WebSocket connection events +# - File upload events + +# ================================================================ +# DEVELOPMENT HELPERS +# ================================================================ + +# Database Development +# Run with Docker: docker run --name typelets-postgres -e POSTGRES_PASSWORD=devpassword -p 5432:5432 -d postgres:15 +# Connect: psql postgresql://postgres:devpassword@localhost:5432/typelets_local + +# Frontend Development URLs +# Vite dev server: http://localhost:5173 +# Next.js dev server: http://localhost:3000 +# React dev server: http://localhost:3000 + +# API Testing +# Health check: GET http://localhost:3000/health +# WebSocket status: GET http://localhost:3000/websocket/status +# API documentation: https://github.com/typelets/typelets-api + +# ================================================================ +# SECURITY NOTES +# ================================================================ + +# ๐Ÿ”’ NEVER commit actual secrets to version control +# ๐Ÿ”’ Use different secrets for development and production +# ๐Ÿ”’ Rotate secrets regularly in production +# ๐Ÿ”’ MESSAGE_AUTH_SECRET is critical for WebSocket security +# ๐Ÿ”’ CLERK_SECRET_KEY controls all authentication +# ๐Ÿ”’ DATABASE_URL contains database credentials + +# Generate secure secrets: +# MESSAGE_AUTH_SECRET: openssl rand -hex 32 +# API Keys: Use your service provider's dashboard +# Database passwords: Use password managers + +# ================================================================ +# PRODUCTION DEPLOYMENT NOTES +# ================================================================ + +# AWS ECS Deployment: +# - Environment variables are set in ECS task definitions +# - Secrets are managed via AWS Secrets Manager +# - This .env file is NOT used in production +# - SSL/TLS termination handled by Application Load Balancer +# - Database connections use IAM authentication or managed secrets + +# Docker Production: +# - Pass environment variables via docker run -e or docker-compose +# - Mount secrets as files or use Docker secrets +# - Never build secrets into Docker images + +# Kubernetes Deployment: +# - Use ConfigMaps for non-sensitive configuration +# - Use Secrets for sensitive data (base64 encoded) +# - Consider external secret management (HashiCorp Vault, etc.) + +# ================================================================ +# TROUBLESHOOTING +# ================================================================ -# Free tier limits for billing -# FREE_TIER_STORAGE_GB=1 # Storage limit in GB for free tier (default: 1) -# FREE_TIER_NOTE_LIMIT=100 # Note count limit for free tier (default: 100) +# Common Issues: +# 1. "Database connection failed" โ†’ Check DATABASE_URL and database status +# 2. "Authentication failed" โ†’ Verify CLERK_SECRET_KEY is correct +# 3. "CORS error" โ†’ Add your frontend URL to CORS_ORIGINS +# 4. "Too many requests" โ†’ Increase rate limits or check for loops +# 5. "WebSocket connection failed" โ†’ Check authentication and rate limits -# Debug logging -# DEBUG=false # Set to true to enable debug logging in production +# Useful Commands: +# Check database connection: npx drizzle-kit studio +# Test API endpoints: curl http://localhost:3000/health +# Monitor logs: tail -f logs/app.log (if file logging enabled) +# Generate new secrets: openssl rand -hex 32 -# IMPORTANT: This .env file is for LOCAL TESTING ONLY -# Production uses AWS ECS task definitions, not .env files \ No newline at end of file +# Support: +# Documentation: https://github.com/typelets/typelets-api +# Issues: https://github.com/typelets/typelets-api/issues \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e4dfb25..1d47ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Real-time WebSocket Sync**: Complete WebSocket implementation for real-time note and folder synchronization + - JWT authentication for WebSocket connections + - Optional HMAC-SHA256 message authentication for enhanced security + - Rate limiting (300 messages/min) and connection limits (20 per user) + - Connection management with automatic cleanup + - Note and folder sync across multiple devices/sessions + - Support for note folder moves via WebSocket +- **Security Enhancements**: + - Comprehensive security headers middleware (CSP, HSTS, XSS protection) + - Enhanced rate limiting middleware + - Production-ready security configuration +- **TypeScript Improvements**: Fixed iterator compatibility and module import issues +- **Documentation**: Complete WebSocket integration documentation and updated security policy + +### Fixed + +- WebSocket note sync issue where `folderId` changes weren't being broadcast +- TypeScript compilation issues with Map iterators and postgres module imports +- Memory leak prevention in nonce storage with automatic cleanup + +### Previous Releases + - Initial open source release - TypeScript API with Hono framework - PostgreSQL database with Drizzle ORM diff --git a/README.md b/README.md index 526fa52..19e6bf1 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The backend API for the [Typelets Application](https://github.com/typelets/typel - ๐Ÿ“Ž **File Attachments** with encrypted storage - ๐Ÿท๏ธ **Tags & Search** for easy note discovery - ๐Ÿ—‘๏ธ **Trash & Archive** functionality +- ๐Ÿ”„ **Real-time Sync** via WebSockets for multi-device support - โšก **Fast & Type-Safe** with TypeScript and Hono - ๐Ÿ˜ **PostgreSQL** with Drizzle ORM @@ -91,6 +92,8 @@ pnpm run dev ๐ŸŽ‰ **Your API is now running at `http://localhost:3000`** +**WebSocket connection available at: `ws://localhost:3000`** + The development server will automatically restart when you make changes to any TypeScript files. ### Why This Setup? @@ -156,6 +159,7 @@ If you prefer to install PostgreSQL locally instead of Docker: - `GET /` - API information and health status - `GET /health` - Health check endpoint +- `GET /websocket/status` - WebSocket server status and statistics ### Authentication @@ -195,6 +199,30 @@ All `/api/*` endpoints require authentication via Bearer token in the Authorizat - `GET /api/files/:id` - Get file details - `DELETE /api/files/:id` - Delete file attachment +### WebSocket Real-time Sync + +The API provides real-time synchronization via WebSocket connection at `ws://localhost:3000` (or your configured port). + +**Connection Flow:** +1. Connect to WebSocket endpoint +2. Send authentication message with Clerk JWT token +3. Join/leave specific notes for real-time updates +4. Receive real-time sync messages for notes and folders + +**Message Types:** +- `auth` - Authenticate with JWT token +- `ping`/`pong` - Heartbeat messages +- `join_note`/`leave_note` - Subscribe/unsubscribe from note updates +- `note_update` - Real-time note content changes and folder moves +- `note_created`/`note_deleted` - Note lifecycle events +- `folder_created`/`folder_updated`/`folder_deleted` - Folder events + +**Security Features:** +- JWT authentication required for all operations +- Authorization checks ensure users only access their own notes/folders +- Rate limiting (configurable, default: 300 messages per minute per connection) +- Connection limits (configurable, default: 20 connections per user) + ## Database Schema The application uses the following main tables: @@ -211,22 +239,28 @@ The application uses the following main tables: - **Input Validation**: Comprehensive Zod schemas for all inputs - **SQL Injection Protection**: Parameterized queries via Drizzle ORM - **CORS Configuration**: Configurable allowed origins -- **Rate Limiting**: Configurable file size limits (default: 50MB per file, 1GB total per note) +- **File Size Limits**: Configurable limits (default: 50MB per file, 1GB total per note) +- **WebSocket Security**: JWT authentication, rate limiting, and connection limits +- **Real-time Authorization**: Database-level ownership validation for all WebSocket operations ## Environment Variables -| Variable | Description | Required | Default | -| --------------------- | -------------------------------------------- | -------- | ----------- | -| `DATABASE_URL` | PostgreSQL connection string | Yes | - | -| `CLERK_SECRET_KEY` | Clerk secret key for JWT verification | Yes | - | -| `CORS_ORIGINS` | Comma-separated list of allowed CORS origins | Yes | - | -| `PORT` | Server port | No | 3000 | -| `NODE_ENV` | Environment (development/production) | No | development | -| `MAX_FILE_SIZE_MB` | Maximum size per file in MB | No | 50 | -| `MAX_NOTE_SIZE_MB` | Maximum total attachments per note in MB | No | 1024 (1GB) | -| `FREE_TIER_STORAGE_GB`| Free tier storage limit in GB | No | 1 | -| `FREE_TIER_NOTE_LIMIT`| Free tier note count limit | No | 100 | -| `DEBUG` | Enable debug logging in production | No | false | +| Variable | Description | Required | Default | +| ---------------------------- | -------------------------------------------- | -------- | ----------- | +| `DATABASE_URL` | PostgreSQL connection string | Yes | - | +| `CLERK_SECRET_KEY` | Clerk secret key for JWT verification | Yes | - | +| `CORS_ORIGINS` | Comma-separated list of allowed CORS origins | Yes | - | +| `PORT` | Server port | No | 3000 | +| `NODE_ENV` | Environment (development/production) | No | development | +| `MAX_FILE_SIZE_MB` | Maximum size per file in MB | No | 50 | +| `MAX_NOTE_SIZE_MB` | Maximum total attachments per note in MB | No | 1024 (1GB) | +| `FREE_TIER_STORAGE_GB` | Free tier storage limit in GB | No | 1 | +| `FREE_TIER_NOTE_LIMIT` | Free tier note count limit | No | 100 | +| `DEBUG` | Enable debug logging in production | No | false | +| `WS_RATE_LIMIT_WINDOW_MS` | WebSocket rate limit window in milliseconds | No | 60000 (1 min) | +| `WS_RATE_LIMIT_MAX_MESSAGES` | Max WebSocket messages per window | No | 300 | +| `WS_MAX_CONNECTIONS_PER_USER`| Max WebSocket connections per user | No | 20 | +| `WS_AUTH_TIMEOUT_MS` | WebSocket authentication timeout in milliseconds | No | 30000 (30 sec) | ## Development @@ -240,15 +274,29 @@ src/ โ”œโ”€โ”€ lib/ โ”‚ โ””โ”€โ”€ validation.ts # Zod validation schemas โ”œโ”€โ”€ middleware/ -โ”‚ โ””โ”€โ”€ auth.ts # Authentication middleware +โ”‚ โ”œโ”€โ”€ auth.ts # Authentication middleware +โ”‚ โ”œโ”€โ”€ rate-limit.ts # Rate limiting middleware +โ”‚ โ””โ”€โ”€ security.ts # Security headers middleware โ”œโ”€โ”€ routes/ -โ”‚ โ”œโ”€โ”€ files.ts # File attachment routes -โ”‚ โ”œโ”€โ”€ folders.ts # Folder management routes -โ”‚ โ”œโ”€โ”€ notes.ts # Note management routes -โ”‚ โ””โ”€โ”€ users.ts # User profile routes +โ”‚ โ”œโ”€โ”€ files.ts # File attachment routes +โ”‚ โ”œโ”€โ”€ folders.ts # Folder management routes +โ”‚ โ”œโ”€โ”€ notes.ts # Note management routes +โ”‚ โ””โ”€โ”€ users.ts # User profile routes โ”œโ”€โ”€ types/ -โ”‚ โ””โ”€โ”€ index.ts # TypeScript type definitions -โ””โ”€โ”€ server.ts # Application entry point +โ”‚ โ””โ”€โ”€ index.ts # TypeScript type definitions +โ”œโ”€โ”€ websocket/ +โ”‚ โ”œโ”€โ”€ auth/ +โ”‚ โ”‚ โ””โ”€โ”€ handler.ts # JWT authentication and HMAC verification +โ”‚ โ”œโ”€โ”€ handlers/ +โ”‚ โ”‚ โ”œโ”€โ”€ base.ts # Base handler for resource operations +โ”‚ โ”‚ โ”œโ”€โ”€ notes.ts # Note sync operations +โ”‚ โ”‚ โ””โ”€โ”€ folders.ts # Folder sync operations +โ”‚ โ”œโ”€โ”€ middleware/ +โ”‚ โ”‚ โ”œโ”€โ”€ connection-manager.ts # Connection tracking and cleanup +โ”‚ โ”‚ โ””โ”€โ”€ rate-limiter.ts # WebSocket rate limiting +โ”‚ โ”œโ”€โ”€ types.ts # WebSocket message types +โ”‚ โ””โ”€โ”€ index.ts # Main WebSocket server manager +โ””โ”€โ”€ server.ts # Application entry point ``` ### Type Safety diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f4ed660 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,176 @@ +# Security Policy + +## Supported Versions + +We actively maintain security updates for the following versions: + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | + +## Security Features + +### Authentication & Authorization +- **JWT Authentication**: Secure token-based authentication using Clerk +- **User Scoped Access**: All data operations are scoped to authenticated users +- **Session Management**: Automatic token validation and user context + +### Input Validation & Sanitization +- **Zod Schema Validation**: Comprehensive input validation on all endpoints +- **SQL Injection Prevention**: Parameterized queries with Drizzle ORM +- **File Upload Security**: Restricted MIME types and filename validation +- **Search Input Sanitization**: Escaped special characters to prevent injection + +### API Security +- **Rate Limiting**: Configurable rate limits per user/IP (100 req/15min default) +- **File Upload Limits**: Stricter limits for file operations (10 req/15min) +- **CORS Configuration**: Restricted to specific allowed origins +- **Request Body Limits**: Configurable file size limits with 35% buffer + +### Security Headers +- **Content Security Policy (CSP)**: Restrictive policy preventing XSS +- **X-Frame-Options**: DENY to prevent clickjacking +- **X-Content-Type-Options**: nosniff to prevent MIME confusion +- **X-XSS-Protection**: Browser XSS filter enabled +- **Strict-Transport-Security**: HSTS in production environments +- **Referrer-Policy**: Strict referrer policy + +### WebSocket Security +- **Authentication Required**: All WS operations require valid JWT +- **Connection Limits**: Maximum 20 connections per user +- **Rate Limiting**: 300 messages per minute per connection +- **Authentication Timeout**: 30 second timeout for unauthenticated connections +- **Message Validation**: All incoming messages validated against schemas +- **HMAC Message Authentication**: Optional cryptographic message signing for enhanced security + +### Data Protection +- **Client-Side Encryption**: Optional end-to-end encryption for sensitive data +- **Database Encryption**: SSL/TLS enforced for database connections +- **Environment Variables**: Sensitive configuration externalized +- **Error Sanitization**: Stack traces hidden in production + +### Infrastructure Security +- **Database Security**: Foreign key constraints and transaction safety +- **Logging**: Security events logged with unique error IDs +- **Environment Separation**: Development vs production error handling + +## Reporting a Vulnerability + +We take security seriously. If you discover a security vulnerability, please follow these steps: + +### 1. **DO NOT** create a public GitHub issue for security vulnerabilities + +### 2. Report privately via: +- **Email**: security@typelets.com +- **GitHub Security Advisories**: Use the private vulnerability reporting feature + +### 3. Include in your report: +- Description of the vulnerability +- Steps to reproduce the issue +- Potential impact assessment +- Suggested remediation (if any) + +### 4. Response Timeline: +- **Initial Response**: Within 48 hours +- **Assessment**: Within 5 business days +- **Fix Timeline**: Varies by severity + - Critical: 24-48 hours + - High: 1 week + - Medium: 2 weeks + - Low: Next minor release + +### 5. Disclosure Policy: +- We follow coordinated disclosure +- Public disclosure after fix is deployed +- Credit will be given to reporters (unless requested otherwise) + +## Security Best Practices for Deployment + +### Environment Configuration +```bash +# Required security environment variables +CLERK_SECRET_KEY=your_clerk_secret_key +DATABASE_URL=postgresql://... # Use SSL in production + +# Optional security configuration +WS_RATE_LIMIT_MAX_MESSAGES=300 +WS_MAX_CONNECTIONS_PER_USER=20 +WS_AUTH_TIMEOUT_MS=30000 +MAX_FILE_SIZE_MB=50 +CORS_ORIGINS=https://yourdomain.com,https://app.yourdomain.com +``` + +### Production Deployment +- Use HTTPS only in production +- Configure proper CORS origins +- Set NODE_ENV=production +- Use secure database connections (SSL) +- Implement proper logging and monitoring +- Regular security updates + +### Rate Limiting +- Default: 100 requests per 15 minutes per user/IP +- File uploads: 10 operations per 15 minutes +- WebSocket: 300 messages per minute per connection +- Configurable via environment variables + +### File Upload Security +- Allowed MIME types: images, PDFs, text files +- Maximum file size: 50MB (configurable) +- Filename validation prevents path traversal +- Client-side encryption recommended for sensitive files + +## Security Checklist for Contributors + +### Code Review Requirements +- [ ] All user inputs validated with Zod schemas +- [ ] Database queries use parameterized statements +- [ ] No secrets in code or logs +- [ ] Error messages don't expose sensitive information +- [ ] Authentication required for protected endpoints +- [ ] Rate limiting considered for expensive operations + +### Testing Requirements +- [ ] Security tests pass +- [ ] Input validation tests included +- [ ] Authentication/authorization tests cover edge cases +- [ ] Error handling tests verify no information leakage + +## Known Security Considerations + +### Limitations +- In-memory rate limiting (resets on server restart) +- In-memory nonce tracking (resets on server restart) +- No distributed session management +- Client-side encryption keys not managed by server + +### Recommendations +- Use Redis for production rate limiting and nonce tracking +- Implement session management for multi-server deployments +- Consider external key management for enterprise use +- Implement LRU cache for nonce management to prevent memory leaks + +## Security Dependencies + +### Regular Updates +We monitor and update dependencies for security vulnerabilities: +- Automated dependency scanning +- Regular security patches +- Major version updates evaluated for security impact + +### Critical Dependencies +- `@clerk/backend` - Authentication +- `drizzle-orm` - Database ORM +- `hono` - Web framework +- `ws` - WebSocket implementation +- `zod` - Input validation + +## Compliance + +This API implements security controls aligned with: +- OWASP Web Application Security Project guidelines +- Modern web security best practices +- Input validation and output encoding standards +- Secure authentication and session management + +For questions about our security practices, please contact security@typelets.com. \ No newline at end of file diff --git a/WEBSOCKET_INTEGRATION.md b/WEBSOCKET_INTEGRATION.md new file mode 100644 index 0000000..f9263ce --- /dev/null +++ b/WEBSOCKET_INTEGRATION.md @@ -0,0 +1,534 @@ +# WebSocket Integration - Backend Implementation + +This document describes the backend WebSocket implementation for real-time note synchronization in the Typelets API. + +## Overview + +The WebSocket server enables: +- **Real-time synchronization** across multiple client sessions +- **Authenticated connections** using Clerk JWT tokens +- **Rate limiting and DoS protection** for production security +- **Optional HMAC message authentication** for enhanced security +- **Connection management** with automatic cleanup + +## Architecture + +### Core Components + +1. **WebSocket Manager** (`src/websocket/index.ts`) + - Main WebSocket server management + - Message routing and handling + - Client connection lifecycle + +2. **Authentication Handler** (`src/websocket/auth/handler.ts`) + - JWT token validation via Clerk + - Optional HMAC message signing verification + - Session management and timeouts + +3. **Message Handlers** + - **Note Handler** (`src/websocket/handlers/notes.ts`) - Note operations + - **Folder Handler** (`src/websocket/handlers/folders.ts`) - Folder operations + +4. **Middleware** + - **Rate Limiter** (`src/websocket/middleware/rate-limiter.ts`) + - **Connection Manager** (`src/websocket/middleware/connection-manager.ts`) + +5. **Type Definitions** (`src/websocket/types.ts`) + - TypeScript interfaces for all WebSocket messages and connections + +## Message Protocol + +### Client โ†’ Server Messages + +```typescript +// Authentication (required first) +{ + type: "auth", + token: "clerk_jwt_token_here" +} + +// Join/leave specific notes for updates +{ + type: "join_note", + noteId: "uuid" +} + +{ + type: "leave_note", + noteId: "uuid" +} + +// Send note updates +{ + type: "note_update", + noteId: "uuid", + changes: { + title?: "New Title", + content?: "New content", + encryptedTitle?: "encrypted_title_here", + encryptedContent?: "encrypted_content_here", + folderId?: "new_folder_id", + starred?: true, + archived?: false, + deleted?: true, + hidden?: false + } +} + +// Notify of new notes/folders +{ + type: "note_created", + noteData: { id: "uuid", title: "New Note", /* full note object */ } +} + +{ + type: "folder_created", + folderData: { id: "uuid", name: "New Folder", /* full folder object */ } +} + +// Notify of deletions +{ + type: "note_deleted", + noteId: "uuid" +} + +{ + type: "folder_deleted", + folderId: "uuid" +} + +// Heartbeat +{ + type: "ping" +} +``` + +### Server โ†’ Client Messages + +```typescript +// Connection established +{ + type: "connection_established", + message: "Please authenticate to continue" +} + +// Authentication responses +{ + type: "auth_success", + message: "Authentication successful", + userId: "user_id", + sessionSecret?: "hex_string" // For HMAC authentication +} + +{ + type: "auth_failed", + message: "Authentication failed", + reason?: "token-expired" | "auth-failed" +} + +// Real-time sync messages +{ + type: "note_sync", + noteId: "uuid", + changes: { title: "Updated Title" }, + updatedNote: { /* complete updated note object */ }, + timestamp: 1234567890, + fromUserId: "user_id" +} + +{ + type: "note_created_sync", + noteData: { /* complete note object */ }, + timestamp: 1234567890, + fromUserId: "user_id" | "server" +} + +{ + type: "note_deleted_sync", + noteId: "uuid", + timestamp: 1234567890, + fromUserId: "user_id" | "server" +} + +// Folder sync messages +{ + type: "folder_created_sync", + folderData: { /* complete folder object */ }, + timestamp: 1234567890, + fromUserId: "user_id" | "server" +} + +{ + type: "folder_updated_sync", + folderId: "uuid", + changes: { name: "Updated Name" }, + updatedFolder: { /* complete folder object */ }, + timestamp: 1234567890, + fromUserId: "user_id" | "server" +} + +{ + type: "folder_deleted_sync", + folderId: "uuid", + timestamp: 1234567890, + fromUserId: "user_id" | "server" +} + +// Operation confirmations +{ + type: "note_update_success", + noteId: "uuid", + updatedNote: { /* complete note object */ }, + timestamp: 1234567890 +} + +{ + type: "note_joined", + noteId: "uuid", + message: "Successfully joined note for real-time sync" +} + +{ + type: "note_left", + noteId: "uuid" +} + +// Heartbeat response +{ + type: "pong", + timestamp: 1234567890 +} + +// Errors +{ + type: "error", + message: "Error description" +} +``` + +## Security Features + +### Authentication + +All WebSocket operations require valid Clerk JWT authentication: + +1. **Connection Flow:** + - Client connects to WebSocket endpoint + - Server sends `connection_established` message + - Client must send `auth` message with JWT token within 30 seconds + - Server validates token with Clerk and responds with `auth_success` or `auth_failed` + +2. **JWT Validation:** + - Uses `@clerk/backend` for secure token verification + - Extracts user ID from token for authorization + - Validates token signature and expiration + +### Optional HMAC Message Authentication + +For enhanced security, the server supports HMAC-SHA256 message signing: + +1. **Session Secret Generation:** + ```typescript + // Generated after successful authentication + const sessionSecret = createHash('sha256') + .update(`${jwtToken}:${userId}:${flooredTimestamp}`) + .digest('hex'); + ``` + +2. **Message Signing (Client-side):** + ```typescript + const messageData = JSON.stringify({ payload, timestamp, nonce }); + const signature = hmacSHA256(sessionSecret, messageData).toBase64(); + + // Send signed message + { + payload: originalMessage, + signature: signature, + timestamp: Date.now(), + nonce: randomBase64String + } + ``` + +3. **Message Verification (Server-side):** + - Regenerates session secret using message timestamp + - Verifies HMAC signature matches + - Checks nonce for replay attack prevention + - Validates message age (5-minute window) + +### Rate Limiting & DoS Protection + +1. **Connection Limits:** + - Maximum 20 connections per user (configurable) + - Automatic cleanup of stale connections + +2. **Message Rate Limiting:** + - 300 messages per minute per connection (configurable) + - 1MB maximum message size + - Sliding window rate limiting + +3. **Nonce Replay Protection:** + - Tracks used nonces with timestamps + - Automatic cleanup of expired nonces (5-minute windows) + - Memory usage limits with emergency cleanup + +## Configuration + +### Environment Variables + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `WS_RATE_LIMIT_WINDOW_MS` | Rate limit window in milliseconds | `60000` | No | +| `WS_RATE_LIMIT_MAX_MESSAGES` | Max messages per window | `300` | No | +| `WS_MAX_CONNECTIONS_PER_USER` | Max connections per user | `20` | No | +| `WS_AUTH_TIMEOUT_MS` | Authentication timeout | `30000` | No | +| `CLERK_SECRET_KEY` | Clerk secret for JWT verification | - | Yes | + +### Production Configuration + +```env +# WebSocket Security Settings +WS_RATE_LIMIT_MAX_MESSAGES=300 +WS_MAX_CONNECTIONS_PER_USER=20 +WS_AUTH_TIMEOUT_MS=30000 + +# Authentication +CLERK_SECRET_KEY=sk_live_your_production_key + +# CORS for WebSocket upgrade requests +CORS_ORIGINS=https://app.yourdomain.com,https://yourdomain.com +``` + +## Database Integration + +### Authorization Checks + +All note/folder operations include ownership validation: + +```typescript +// Example from note update handler +const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, noteId), eq(notes.userId, userId)) +}); + +if (!existingNote) { + // Access denied - user doesn't own this note + return sendError("Note not found or access denied"); +} +``` + +### Allowed Field Updates + +Note updates are restricted to safe fields only: + +```typescript +const allowedFields = [ + 'title', 'content', 'encryptedTitle', 'encryptedContent', + 'starred', 'archived', 'deleted', 'hidden', 'folderId' +]; +``` + +Fields like `id`, `userId`, `createdAt` are protected from modification. + +## Error Handling + +### Client Errors + +- **Authentication timeout**: Connection closed after 30 seconds without auth +- **Rate limit exceeded**: Temporary message rejection with retry advice +- **Invalid message format**: Error response with format requirements +- **Authorization failed**: Access denied for unauthorized operations + +### Server Errors + +- **Database errors**: Logged server-side, generic error to client +- **JWT validation errors**: Specific error types for debugging +- **Message processing errors**: Detailed logging with correlation IDs + +## Performance Considerations + +### Memory Management + +1. **Connection Tracking:** + - WeakMap references for automatic garbage collection + - Periodic cleanup of stale connections + - Memory usage monitoring and limits + +2. **Nonce Storage:** + - Time-based cleanup every 5 minutes + - Emergency cleanup at 10,000 nonces + - LRU eviction for memory efficiency + +3. **Message Queuing:** + - No persistent message queuing (stateless design) + - Clients responsible for handling missed messages + - Connection status indicators for client awareness + +### Scalability + +Current implementation uses in-memory storage suitable for single-instance deployments. For multi-instance scaling: + +- **Recommended**: Redis for shared rate limiting and nonce storage +- **Alternative**: Database-backed connection management +- **Load Balancing**: Sticky sessions or Redis pub/sub for message broadcasting + +## Development + +### Starting the WebSocket Server + +The WebSocket server starts automatically with the main API server: + +```bash +# Development +npm run dev + +# Production +npm run start +``` + +WebSocket endpoint available at: `ws://localhost:3000` (or configured port) + +### Debugging + +Enable detailed logging in development: + +```bash +# Set debug environment +DEBUG=websocket:* npm run dev + +# Or enable in code +console.log('WebSocket debug info:', { + connectionCount: connectionManager.getConnectionStats(), + messageType: message.type, + userId: ws.userId +}); +``` + +### Testing with Multiple Clients + +1. Open multiple browser tabs to your frontend +2. Authenticate each session +3. Join the same note in different tabs +4. Make changes in one tab to see real-time sync in others + +## API Integration + +### Server-Triggered Notifications + +The WebSocket manager provides methods for server-initiated sync: + +```typescript +// From REST API endpoints, trigger WebSocket sync +const wsManager = WebSocketManager.getInstance(); + +// Notify user's devices of note changes +wsManager?.notifyNoteUpdate(userId, noteId, changes, updatedNote); +wsManager?.notifyNoteCreated(userId, noteData); +wsManager?.notifyNoteDeleted(userId, noteId); + +// Notify folder changes +wsManager?.notifyFolderCreated(userId, folderData); +wsManager?.notifyFolderUpdated(userId, folderId, changes, updatedFolder); +wsManager?.notifyFolderDeleted(userId, folderId); +``` + +### Connection Statistics + +Monitor WebSocket health via REST endpoint: + +```http +GET /websocket/status + +Response: +{ + "status": "healthy", + "connections": { + "total": 15, + "authenticated": 12, + "perUser": [ + {"userId": "user_123", "deviceCount": 3} + ] + }, + "uptime": "2h 30m" +} +``` + +## Security Best Practices + +### Production Deployment + +1. **Use HTTPS/WSS**: Always use secure WebSocket connections in production +2. **JWT Secret Rotation**: Regularly rotate Clerk secret keys +3. **Rate Limiting**: Configure appropriate limits based on usage patterns +4. **Connection Monitoring**: Track connection patterns for abuse detection +5. **Error Logging**: Log security events without exposing sensitive data + +### Client Implementation + +1. **Token Management**: Handle JWT token refresh gracefully +2. **Reconnection Logic**: Implement exponential backoff for reconnections +3. **Message Validation**: Validate all incoming messages on client-side +4. **Error Handling**: Graceful degradation when WebSocket unavailable + +### Monitoring + +1. **Connection Metrics**: Track connection counts and patterns +2. **Message Metrics**: Monitor message rates and types +3. **Error Rates**: Alert on authentication failures or rate limiting +4. **Performance**: Monitor message latency and processing time + +## Troubleshooting + +### Common Issues + +**WebSocket connections fail:** +- Verify CORS configuration allows WebSocket upgrades +- Check that Clerk secret key is correctly configured +- Ensure no proxy/firewall blocking WebSocket connections + +**Authentication errors:** +- Validate JWT tokens are not expired +- Check Clerk configuration matches frontend +- Verify token is passed correctly in auth message + +**Missing sync messages:** +- Ensure clients properly join notes with `join_note` message +- Check that database updates are triggering WebSocket broadcasts +- Verify no errors in message handlers preventing broadcast + +**Memory usage issues:** +- Monitor nonce storage cleanup frequency +- Check for connection leaks in connection manager +- Review rate limiting settings for efficiency + +### Debug Logging + +Key logging points for troubleshooting: + +```typescript +// Connection events +console.log(`WebSocket connection established for user ${userId}`); + +// Authentication events +console.log(`JWT authentication successful for user ${userId}`); + +// Message processing +console.log(`Processing ${message.type} message from user ${userId}`); + +// Broadcasting +console.log(`Broadcasted to ${sentCount} devices for user ${userId}`); +``` + +## Contributing + +When modifying the WebSocket implementation: + +1. **Update type definitions** in `src/websocket/types.ts` +2. **Maintain backward compatibility** with existing message formats +3. **Add security validation** for new message types +4. **Update this documentation** with any protocol changes +5. **Test with multiple clients** to verify real-time sync works +6. **Consider rate limiting impact** of new message types + +## License + +This WebSocket implementation is part of the Typelets API and follows the same MIT license. \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 12743dc..7c55cae 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,12 @@ export default [ globals: { ...globals.node, // Adds Node.js globals like 'process', 'Buffer', etc. ...globals.es2021, // Adds modern JavaScript globals + RequestInit: "readonly", + Request: "readonly", + Response: "readonly", + Headers: "readonly", + BodyInit: "readonly", + BufferSource: "readonly", }, }, plugins: { @@ -24,9 +30,14 @@ export default [ }, rules: { // Add your custom rules here - "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "ignoreRestSiblings": true + }], "@typescript-eslint/no-explicit-any": "warn", "no-undef": "error", + "no-unused-vars": "off", // Turn off base rule to avoid conflicts with @typescript-eslint version }, }, { diff --git a/package.json b/package.json index e29936a..09e6854 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,13 @@ "@clerk/backend": "^2.5.0", "@hono/node-server": "^1.15.0", "@hono/zod-validator": "^0.7.0", + "@types/ws": "^8.18.1", "dotenv": "^17.0.1", "dotenv-flow": "^4.1.0", "drizzle-orm": "^0.44.2", "hono": "^4.8.3", "postgres": "^3.4.7", + "ws": "^8.18.3", "zod": "^3.25.67" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07cd9a9..257e772 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@hono/zod-validator': specifier: ^0.7.0 version: 0.7.2(hono@4.9.6)(zod@3.25.76) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 dotenv: specifier: ^17.0.1 version: 17.2.2 @@ -32,6 +35,9 @@ importers: postgres: specifier: ^3.4.7 version: 3.4.7 + ws: + specifier: ^8.18.3 + version: 8.18.3 zod: specifier: ^3.25.67 version: 3.25.76 @@ -942,6 +948,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -3097,6 +3106,18 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4060,6 +4081,10 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.3.1 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -6244,6 +6269,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + ws@8.18.3: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/db/index.ts b/src/db/index.ts index f0bb82b..44abb10 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,13 +1,13 @@ import "dotenv/config"; import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; +import * as postgres from "postgres"; import * as schema from "./schema"; if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL environment variable is required"); } -const client = postgres(process.env.DATABASE_URL, { +const client = postgres.default(process.env.DATABASE_URL, { ssl: process.env.NODE_ENV === "production" ? "require" : false, }); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index e0299f4..c41b25e 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -65,7 +65,7 @@ export const updateNoteSchema = z.object({ export const paginationSchema = z.object({ page: z.coerce.number().min(1).default(1), - limit: z.coerce.number().min(1).max(100).default(20), + limit: z.coerce.number().min(1).max(50).default(20), // Reduced max limit for security }); export const notesQuerySchema = z @@ -75,7 +75,11 @@ export const notesQuerySchema = z archived: z.coerce.boolean().optional(), deleted: z.coerce.boolean().optional(), hidden: z.coerce.boolean().optional(), - search: z.string().max(100).optional(), + search: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9\s\-_.,!?]+$/, "Search contains invalid characters") // Only allow safe characters + .optional(), }) .merge(paginationSchema); @@ -85,9 +89,48 @@ export const foldersQuerySchema = z }) .merge(paginationSchema); +// Allowed MIME types for security +const allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'text/markdown', + 'application/json', + 'text/csv', +] as const; + export const uploadFileSchema = z.object({ - originalName: z.string().min(1).max(255), - mimeType: z.string().min(1).max(100), + originalName: z + .string() + .min(1) + .max(255) + .refine( + (name) => { + // Check for dangerous characters and patterns + const dangerousChars = /[<>:"/\\|?*]/; + // Check for control characters (ASCII 0-31) + const hasControlChars = name.split('').some(char => { + const code = char.charCodeAt(0); + return code >= 0 && code <= 31; + }); + const dangerousPatterns = /^\./; // Files starting with dot + + return !dangerousChars.test(name) && + !hasControlChars && + !dangerousPatterns.test(name); + }, + "Invalid filename characters" + ), + mimeType: z + .string() + .refine( + (type): type is typeof allowedMimeTypes[number] => + allowedMimeTypes.includes(type as typeof allowedMimeTypes[number]), + "File type not allowed" + ), size: z .number() .int() diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts new file mode 100644 index 0000000..181bcfe --- /dev/null +++ b/src/middleware/rate-limit.ts @@ -0,0 +1,116 @@ +import { Context, Next } from "hono"; +import { HTTPException } from "hono/http-exception"; + +interface RateLimitStore { + count: number; + resetTime: number; +} + +class InMemoryRateLimitStore { + private store = new Map(); + + get(key: string): RateLimitStore | undefined { + const entry = this.store.get(key); + if (entry && Date.now() > entry.resetTime) { + this.store.delete(key); + return undefined; + } + return entry; + } + + set(key: string, value: RateLimitStore): void { + this.store.set(key, value); + } + + // Cleanup expired entries periodically + cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.store.entries()) { + if (now > entry.resetTime) { + this.store.delete(key); + } + } + } +} + +const store = new InMemoryRateLimitStore(); + +// Cleanup expired entries every 5 minutes +let cleanupInterval: ReturnType | null = setInterval(() => store.cleanup(), 5 * 60 * 1000); + +// Graceful cleanup function +export const cleanup = (): void => { + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } +}; + +interface RateLimitOptions { + windowMs: number; + max: number; + keyGenerator?: (c: Context) => string; + skipSuccessfulRequests?: boolean; + skipFailedRequests?: boolean; +} + +export const rateLimit = (options: RateLimitOptions) => { + const { + windowMs = 15 * 60 * 1000, // 15 minutes + max = 100, + keyGenerator = (c: Context) => { + // Use combination of IP and user ID for authenticated requests + const userId = c.get("userId"); + const ip = c.env?.CF_CONNECTING_IP || + c.req.header("x-forwarded-for")?.split(",")[0] || + c.req.header("x-real-ip") || + "unknown"; + return userId ? `${userId}:${ip}` : ip; + }, + skipSuccessfulRequests = false, + skipFailedRequests = false, + } = options; + + return async (c: Context, next: Next): Promise => { + const key = keyGenerator(c); + const now = Date.now(); + const resetTime = now + windowMs; + + let entry = store.get(key); + + if (!entry) { + entry = { count: 0, resetTime }; + store.set(key, entry); + } + + entry.count++; + + if (entry.count > max) { + throw new HTTPException(429, { + message: "Too Many Requests", + cause: { + retryAfter: Math.ceil((entry.resetTime - now) / 1000), + limit: max, + remaining: 0, + reset: entry.resetTime, + } + }); + } + + // Add rate limit headers + c.res.headers.set("X-RateLimit-Limit", max.toString()); + c.res.headers.set("X-RateLimit-Remaining", Math.max(0, max - entry.count).toString()); + c.res.headers.set("X-RateLimit-Reset", Math.ceil(entry.resetTime / 1000).toString()); + + await next(); + + // Optionally skip counting successful/failed requests + const shouldSkip = + (skipSuccessfulRequests && c.res.status < 400) || + (skipFailedRequests && c.res.status >= 400); + + if (shouldSkip) { + entry.count--; + } + }; +}; \ No newline at end of file diff --git a/src/middleware/security.ts b/src/middleware/security.ts new file mode 100644 index 0000000..866f6ef --- /dev/null +++ b/src/middleware/security.ts @@ -0,0 +1,38 @@ +import { Context, Next } from "hono"; + +export const securityHeaders = async (c: Context, next: Next): Promise => { + await next(); + + // Content Security Policy + c.res.headers.set( + "Content-Security-Policy", + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https:; " + + "font-src 'self'; " + + "connect-src 'self'; " + + "media-src 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "form-action 'self'; " + + "frame-ancestors 'none'; " + + "upgrade-insecure-requests" + ); + + // Security headers + c.res.headers.set("X-Content-Type-Options", "nosniff"); + c.res.headers.set("X-Frame-Options", "DENY"); + c.res.headers.set("X-XSS-Protection", "1; mode=block"); + c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + c.res.headers.set("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); + + // HSTS (only in production with HTTPS) + if (process.env.NODE_ENV === "production") { + c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload"); + } + + // Remove server identification + c.res.headers.delete("Server"); + c.res.headers.delete("X-Powered-By"); +}; \ No newline at end of file diff --git a/src/routes/folders.ts b/src/routes/folders.ts index a502d6c..1ac054f 100644 --- a/src/routes/folders.ts +++ b/src/routes/folders.ts @@ -298,7 +298,12 @@ foldersRouter.delete("/:id", async (c) => { try { await db.transaction(async (tx) => { // Delete the folder - await tx.delete(folders).where(eq(folders.id, folderId)); + const deleteResult = await tx.delete(folders).where(eq(folders.id, folderId)); + + // Verify deletion succeeded + if (deleteResult.rowCount === 0) { + throw new Error(`Folder ${folderId} was not found or could not be deleted`); + } // Reorder remaining folders to fill the gap const remainingFolders = await tx.query.folders.findMany({ @@ -312,19 +317,31 @@ foldersRouter.delete("/:id", async (c) => { }); // Update sort order for remaining folders - const updatePromises = remainingFolders.map((folder, index) => - tx - .update(folders) - .set({ sortOrder: index }) - .where(eq(folders.id, folder.id)), - ); - - await Promise.all(updatePromises); + if (remainingFolders.length > 0) { + const updatePromises = remainingFolders.map((folder, index) => + tx + .update(folders) + .set({ sortOrder: index }) + .where(eq(folders.id, folder.id)), + ); + + const updateResults = await Promise.all(updatePromises); + + // Verify all updates succeeded + const failedUpdates = updateResults.filter(result => result.rowCount === 0); + if (failedUpdates.length > 0) { + throw new Error(`Failed to reorder ${failedUpdates.length} folder(s) after deletion`); + } + } }); return c.json({ message: "Folder deleted successfully" }); - } catch { - throw new HTTPException(500, { message: "Failed to delete folder" }); + } catch (error) { + console.error(`Failed to delete folder ${folderId}:`, error); + throw new HTTPException(500, { + message: "Failed to delete folder", + cause: error instanceof Error ? error.message : "Unknown error" + }); } }); diff --git a/src/routes/notes.ts b/src/routes/notes.ts index 5496d45..47df001 100644 --- a/src/routes/notes.ts +++ b/src/routes/notes.ts @@ -40,10 +40,16 @@ notesRouter.get("/", zValidator("query", notesQuerySchema), async (c) => { } if (query.search) { + // Escape special SQL LIKE characters to prevent injection + const escapedSearch = query.search + .replace(/\\/g, '\\\\') + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); + conditions.push( or( - ilike(notes.title, `%${query.search}%`), - ilike(notes.content, `%${query.search}%`), + ilike(notes.title, `%${escapedSearch}%`), + ilike(notes.content, `%${escapedSearch}%`), )!, ); } diff --git a/src/server.ts b/src/server.ts index ba6f54e..1745f30 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,10 +1,15 @@ +/// import "dotenv-flow/config"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { bodyLimit } from "hono/body-limit"; import { HTTPException } from "hono/http-exception"; -import { serve } from "@hono/node-server"; +// import { serve } from "@hono/node-server"; // Unused since we use custom HTTP server +import { createServer } from "http"; +import { WebSocketManager } from "./websocket"; import { authMiddleware } from "./middleware/auth"; +import { securityHeaders } from "./middleware/security"; +import { rateLimit, cleanup as rateLimitCleanup } from "./middleware/rate-limit"; import foldersRouter from "./routes/folders"; import notesRouter from "./routes/notes"; import usersRouter from "./routes/users"; @@ -18,6 +23,27 @@ const maxBodySize = Math.ceil(maxFileSize * 1.35); const app = new Hono(); +// Apply security headers first +app.use("*", securityHeaders); + +// Apply rate limiting +app.use( + "*", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 1000, // 1000 requests per window (increased from 100) + }) +); + +// Rate limiting for file uploads +app.use( + "/api/files/*", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // 100 file operations per window (increased from 10) + }) +); + app.use( "*", bodyLimit({ @@ -38,15 +64,9 @@ app.use("*", async (c, next) => { await next(); }); -if (!process.env.CORS_ORIGINS) { - throw new Error( - "Missing CORS_ORIGINS - Please add CORS_ORIGINS to your environment variables", - ); -} - -const corsOrigins = process.env.CORS_ORIGINS.split(",").map((origin) => - origin.trim(), -); +const corsOrigins = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(",").map((origin) => origin.trim()) + : ["http://localhost:3000", "http://localhost:5173"]; app.use( "*", @@ -84,7 +104,18 @@ app.get("/health", (c) => { version: VERSION, timestamp: new Date().toISOString(), uptime: process.uptime(), - env: process.env.NODE_ENV || "development", + }); +}); + +app.get("/websocket/status", (c) => { + if (!wsManager) { + return c.json({ error: "WebSocket not initialized" }, 500); + } + + return c.json({ + websocket: "operational", + stats: wsManager.getConnectionStats(), + timestamp: new Date().toISOString(), }); }); @@ -96,37 +127,53 @@ app.route("/api/notes", notesRouter); app.route("/api", filesRouter); app.onError((err, c) => { - console.error("API Error:", err.message); - console.error("Stack:", err.stack); + // Generate unique error ID for tracking + const errorId = crypto.randomUUID(); + + // Log full error details server-side only + console.error(`[ERROR ${errorId}] API Error:`, err.message); + console.error(`[ERROR ${errorId}] Stack:`, err.stack); + console.error(`[ERROR ${errorId}] URL:`, c.req.url); + console.error(`[ERROR ${errorId}] Method:`, c.req.method); + console.error(`[ERROR ${errorId}] User:`, c.get("userId") || "anonymous"); if (err instanceof HTTPException) { // Log usage limit errors for billing analytics if (err.status === 402 && err.cause) { const userId = c.get("userId") || "anonymous"; const cause = err.cause as { code: string; currentCount?: number; limit?: number; currentStorageMB?: number; fileSizeMB?: number; expectedTotalMB?: number; limitGB?: number }; - + if (cause.code === "NOTE_LIMIT_EXCEEDED") { console.log(`[BILLING] Note limit exceeded - User: ${userId}, Count: ${cause.currentCount}/${cause.limit}`); } else if (cause.code === "STORAGE_LIMIT_EXCEEDED") { console.log(`[BILLING] Storage limit exceeded - User: ${userId}, Storage: ${cause.currentStorageMB}MB + ${cause.fileSizeMB}MB = ${cause.expectedTotalMB}MB (Limit: ${cause.limitGB}GB)`); } } - + + // Return sanitized error response return c.json( { error: err.message, status: err.status, timestamp: new Date().toISOString(), + ...(process.env.NODE_ENV === "development" && { errorId }) }, err.status, ); } + // For non-HTTP exceptions, return generic error in production + const isProduction = process.env.NODE_ENV === "production"; + return c.json( { - error: "Internal Server Error", + error: isProduction ? "Internal Server Error" : err.message, status: 500, timestamp: new Date().toISOString(), + ...(process.env.NODE_ENV === "development" && { + errorId, + stack: err.stack + }) }, 500, ); @@ -161,7 +208,79 @@ console.log( console.log(`๐Ÿ’ฐ Free tier limits: ${freeStorageGB}GB storage, ${freeNoteLimit} notes`); console.log(`๐ŸŒ CORS origins:`, corsOrigins); -serve({ - fetch: app.fetch, - port, +const httpServer = createServer((req, res) => { + let body = Buffer.alloc(0); + + req.on('data', (chunk: Buffer) => { + body = Buffer.concat([body, chunk]); + }); + + req.on('end', async () => { + try { + const requestInit: RequestInit = { + method: req.method, + headers: req.headers as Record, + }; + + if (body.length > 0) { + requestInit.body = new Uint8Array(body) as BodyInit; + } + + const response = await app.fetch(new Request(`http://localhost${req.url}`, requestInit)); + + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + const buffer = await response.arrayBuffer(); + res.end(Buffer.from(buffer)); + } catch (err) { + console.error('Request handling error:', err); + res.statusCode = 500; + res.end('Internal Server Error'); + } + }); + + req.on('error', (err: Error) => { + console.error('Request error:', err); + res.statusCode = 500; + res.end('Internal Server Error'); + }); }); + +const wsManager = new WebSocketManager(httpServer); + +httpServer.listen(port, () => { + console.log(`๐Ÿš€ Typelets API v${VERSION} with WebSocket started at:`, new Date().toISOString()); + console.log(`๐Ÿ“ก HTTP & WebSocket server listening on port ${port}`); + console.log(`๐Ÿ“ Max file size: ${maxFileSize}MB (body limit: ${maxBodySize}MB)`); + console.log(`๐Ÿ’ฐ Free tier limits: ${freeStorageGB}GB storage, ${freeNoteLimit} notes`); + console.log(`๐ŸŒ CORS origins:`, corsOrigins); +}); + +// Graceful shutdown handling +const shutdown = (signal: string) => { + console.log(`\n๐Ÿ›‘ Received ${signal}, starting graceful shutdown...`); + + // Stop accepting new connections + httpServer.close(() => { + console.log('๐Ÿ“ด HTTP server closed'); + + // Cleanup rate limiter + rateLimitCleanup(); + console.log('๐Ÿงน Rate limiter cleanup completed'); + + console.log('โœ… Graceful shutdown completed'); + process.exit(0); + }); + + // Force shutdown after 10 seconds + setTimeout(() => { + console.error('โŒ Forced shutdown after timeout'); + process.exit(1); + }, 10000); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/websocket/auth/handler.ts b/src/websocket/auth/handler.ts new file mode 100644 index 0000000..2f33c0a --- /dev/null +++ b/src/websocket/auth/handler.ts @@ -0,0 +1,282 @@ +import { verifyToken } from "@clerk/backend"; +import { createHash, createHmac } from "crypto"; +import { AuthenticatedWebSocket, WebSocketMessage, WebSocketConfig } from '../types'; +import { ConnectionManager } from '../middleware/connection-manager'; + +interface AuthenticatedMessage { + payload: WebSocketMessage; + signature: string; + timestamp: number; + nonce: string; +} + +export class AuthHandler { + private usedNonces = new Map(); // Store with timestamp + private readonly MAX_NONCES = 10000; // Prevent memory exhaustion + + constructor( + private readonly connectionManager: ConnectionManager, + private readonly _config: WebSocketConfig + ) { + // Cleanup old nonces every 5 minutes + setInterval(() => this.cleanupOldNonces(), 5 * 60 * 1000); + } + + private cleanupOldNonces(): void { + const now = Date.now(); + const fiveMinutesAgo = now - (5 * 60 * 1000); + + // Remove expired nonces + const noncesToDelete: string[] = []; + this.usedNonces.forEach((timestamp, nonce) => { + if (timestamp < fiveMinutesAgo) { + noncesToDelete.push(nonce); + } + }); + + // Delete expired nonces + noncesToDelete.forEach(nonce => { + this.usedNonces.delete(nonce); + }); + + // Emergency cleanup if too many nonces (DoS protection) + if (this.usedNonces.size > this.MAX_NONCES) { + console.warn(`Nonce storage exceeded limit (${this.MAX_NONCES}), clearing all nonces`); + this.usedNonces.clear(); + } + + } + + setupAuthTimeout(ws: AuthenticatedWebSocket): void { + ws.authTimeout = setTimeout(() => { + if (!ws.isAuthenticated) { + console.log("WebSocket connection closed due to authentication timeout"); + ws.send(JSON.stringify({ + type: "error", + message: "Authentication timeout. Connection will be closed." + })); + ws.close(); + } + }, this._config.authTimeoutMs); + } + + async handleAuthentication(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + try { + if (!message.token) { + throw new Error("Token is required"); + } + + const payload = await verifyToken(message.token, { + secretKey: process.env.CLERK_SECRET_KEY!, + }); + + const userId = payload.sub; + + // Check connection limit before allowing authentication + if (!this.connectionManager.checkConnectionLimit(userId)) { + ws.send(JSON.stringify({ + type: "error", + message: "Maximum connections exceeded for this user" + })); + ws.close(); + return; + } + + ws.userId = userId; + ws.isAuthenticated = true; + ws.jwtToken = message.token; // Store JWT token for signature verification + + + // Clear authentication timeout since user is now authenticated + if (ws.authTimeout) { + clearTimeout(ws.authTimeout); + ws.authTimeout = undefined; + } + + // Generate session secret for auth_success response (matching frontend exactly) + const timestamp = Date.now(); + const flooredTimestamp = Math.floor(timestamp / 300000) * 300000; // 5-minute window + + const sessionSecret = createHash('sha256') + .update(`${message.token}:${userId}:${flooredTimestamp}`) + .digest('hex'); + + + // Store session secret for this connection (for reference only) + ws.sessionSecret = sessionSecret; + + this.connectionManager.addUserConnection(userId, ws); + + ws.send(JSON.stringify({ + type: "auth_success", + message: "Authentication successful", + userId: ws.userId, + sessionSecret: sessionSecret + })); + + console.log(`User ${ws.userId} authenticated via WebSocket`); + } catch (error: unknown) { + console.error("WebSocket authentication failed:", error); + + const isTokenExpired = (error as Record)?.reason === 'token-expired'; + + ws.send(JSON.stringify({ + type: "auth_failed", + message: isTokenExpired ? "Token expired" : "Authentication failed", + reason: isTokenExpired ? "token-expired" : "auth-failed" + })); + ws.close(); + } + } + + /** + * Verify message signature for authenticated messages (Phase 2) + * @param authMessage - The authenticated message structure + * @param storedSessionSecret - The session secret for this connection (hex string) + * @param jwtToken - The JWT token for regenerating session secret + * @param userId - The user ID for regenerating session secret + * @returns Promise - True if signature is valid + */ + async verifyMessageSignature(authMessage: AuthenticatedMessage, storedSessionSecret: string, jwtToken?: string, userId?: string): Promise { + const { payload, signature, timestamp, nonce } = authMessage; + + // 1. Timestamp validation (5-minute window + 1 minute tolerance for clock skew) + const messageAge = Date.now() - timestamp; + const MAX_MESSAGE_AGE = 5 * 60 * 1000; // 5 minutes + if (messageAge > MAX_MESSAGE_AGE || messageAge < -60000) { // -60 seconds tolerance for clock skew + console.warn('Message rejected: timestamp out of range'); + return false; + } + + // 2. Check for replay attack using nonce + const nonceKey = `${nonce}:${timestamp}`; + if (this.usedNonces.has(nonceKey)) { + console.warn('Message rejected: nonce already used (replay attack)'); + return false; + } + + try { + // 3. Validate required parameters + if (!jwtToken || !userId) { + console.error('Missing JWT token or user ID for signature verification'); + return false; + } + + + // 4. Regenerate session secret for this timestamp window (matching frontend exactly) + const flooredTimestamp = Math.floor(timestamp / 300000) * 300000; + const sessionSecretInput = `${jwtToken}:${userId}:${flooredTimestamp}`; + + + const sessionSecret = createHash('sha256') + .update(sessionSecretInput, 'utf8') + .digest('hex'); + + + // 5. FIXED: Use hex session secret directly as HMAC key (matching frontend) + // Frontend uses the hex string directly, not converted to buffer + const hmacKey = sessionSecret; // Use hex string directly + + // 6. Create exact message data that was signed (order matters!) + const messageToSign = { payload, timestamp, nonce }; + const messageData = JSON.stringify(messageToSign); + + + // 7. Generate expected signature using hex string directly + const expectedSignature = createHmac('sha256', hmacKey) + .update(messageData, 'utf8') + .digest('base64'); + + // 8. Test stored session secret with same approach + const storedSecretSignature = createHmac('sha256', storedSessionSecret) + .update(messageData, 'utf8') + .digest('base64'); + + // Test buffer conversion for comparison (kept for debugging) + const secretBuffer = Buffer.from(sessionSecret, 'hex'); + const _altSignature1 = createHmac('sha256', secretBuffer) + .update(messageData, 'utf8') + .digest('base64'); + + const _altSignature2 = createHmac('sha256', sessionSecret) + .update(messageData, 'utf8') + .digest('base64'); + + + // 9. Compare signatures - check both regenerated and stored secret approaches + const isValidRegenerated = expectedSignature === signature; + const isValidStored = storedSecretSignature === signature; + const isValid = isValidRegenerated || isValidStored; + + + + if (!isValid) { + console.warn('Message signature verification failed for user', userId); + } else { + console.debug('Message signature verified successfully for user', userId); + } + + if (isValid) { + // Mark nonce as used with current timestamp + this.usedNonces.set(nonceKey, Date.now()); + } + + return isValid; + } catch (error) { + console.error('Error verifying message signature:', error); + return false; + } + } + + /** + * Process incoming WebSocket message with optional authentication + * @param ws - The WebSocket connection + * @param rawMessage - The raw message (could be authenticated or plain) + * @returns The extracted message payload or null if verification fails + */ + async processIncomingMessage(ws: AuthenticatedWebSocket, rawMessage: unknown): Promise { + // Type guard to check if this is an authenticated message structure + if (this.isAuthenticatedMessage(rawMessage)) { + + // This is an authenticated message, verify signature + if (!ws.sessionSecret) { + console.warn('Authenticated message received but no session secret available'); + return null; + } + + const isValid = await this.verifyMessageSignature(rawMessage, ws.sessionSecret, ws.jwtToken, ws.userId); + if (!isValid) { + console.warn('Message signature verification failed for user', ws.userId); + return null; + } + + // Return the payload from authenticated message + return rawMessage.payload; + } + + + // Handle non-authenticated messages (backward compatibility) + return rawMessage as WebSocketMessage; + } + + /** + * Type guard to check if a message has authentication structure + */ + private isAuthenticatedMessage(message: unknown): message is AuthenticatedMessage { + if (typeof message !== 'object' || message === null) { + return false; + } + + const msg = message as Record; + + return ( + 'payload' in msg && + 'signature' in msg && + 'timestamp' in msg && + 'nonce' in msg && + typeof msg.signature === 'string' && + typeof msg.timestamp === 'number' && + typeof msg.nonce === 'string' + ); + } +} \ No newline at end of file diff --git a/src/websocket/handlers/base.ts b/src/websocket/handlers/base.ts new file mode 100644 index 0000000..69a7a1f --- /dev/null +++ b/src/websocket/handlers/base.ts @@ -0,0 +1,111 @@ +import { db, notes, folders } from "../../db"; +import { eq, and } from "drizzle-orm"; +import { AuthenticatedWebSocket, WebSocketMessage, ResourceOperationConfig } from '../types'; +import { ConnectionManager } from '../middleware/connection-manager'; + +export class BaseResourceHandler { + constructor(protected readonly _connectionManager: ConnectionManager) {} + + async handleResourceOperation( + ws: AuthenticatedWebSocket, + message: WebSocketMessage, + config: ResourceOperationConfig + ): Promise { + const resourceId = message[config.idField] as string; + const resourceData = config.dataField ? message[config.dataField] : undefined; + + // Validate required fields + if (!ws.userId || !resourceId || (config.dataField && !resourceData)) { + const missingFields = []; + if (!ws.userId) missingFields.push('userId'); + if (!resourceId) missingFields.push(config.idField); + if (config.dataField && !resourceData) missingFields.push(config.dataField); + + ws.send(JSON.stringify({ + type: "error", + message: `Missing ${missingFields.join(', ')}` + })); + return; + } + + // Authorization check - MANDATORY for all operations except creation + if (config.operation !== 'created') { + if (!config.tableName) { + throw new Error(`Authorization required: tableName must be provided for ${config.operation} operations`); + } + + try { + let existingResource; + + if (config.tableName === 'folders') { + existingResource = await db.query.folders.findFirst({ + where: and(eq(folders.id, resourceId), eq(folders.userId, ws.userId)), + }); + } else if (config.tableName === 'notes') { + existingResource = await db.query.notes.findFirst({ + where: and(eq(notes.id, resourceId), eq(notes.userId, ws.userId)), + }); + } else { + throw new Error(`Unsupported table name: ${config.tableName}`); + } + + if (!existingResource) { + ws.send(JSON.stringify({ + type: "error", + message: `${config.resourceType.charAt(0).toUpperCase() + config.resourceType.slice(1)} not found or access denied` + })); + return; + } + } catch (error) { + console.error(`Error authorizing ${config.resourceType} ${config.operation}:`, error); + ws.send(JSON.stringify({ + type: "error", + message: `Failed to ${config.operation.replace('d', '')} ${config.resourceType}` + })); + return; + } + } + + // For created operations, ensure the user owns the created resource + if (config.operation === 'created' && resourceData) { + const createdByUserId = (resourceData as Record).userId; + if (createdByUserId !== ws.userId) { + ws.send(JSON.stringify({ + type: "error", + message: "Access denied: Cannot broadcast resource created by another user" + })); + return; + } + } + + console.log(`User ${ws.userId} ${config.logAction}`); + + // Build sync message + const syncMessage: Record = { + type: config.syncMessageType, + timestamp: Date.now(), + fromUserId: ws.userId + }; + + // Add resource-specific data + if (config.operation === 'created' && resourceData && config.dataField) { + syncMessage[config.dataField] = resourceData; + } else if (config.operation === 'updated') { + syncMessage[config.idField] = resourceId; + syncMessage.changes = message.changes; + + // Add updated resource data based on resource type + const updatedFieldName = `updated${config.resourceType.charAt(0).toUpperCase() + config.resourceType.slice(1)}`; + const updatedData = message[updatedFieldName as keyof WebSocketMessage]; + if (updatedData) { + syncMessage[updatedFieldName] = updatedData; + } + } else if (config.operation === 'deleted') { + syncMessage[config.idField] = resourceId; + } + + // Broadcast to user devices + const sentCount = this._connectionManager.broadcastToUserDevices(ws.userId, syncMessage, ws); + console.log(`Broadcasted message to ${sentCount} devices for user ${ws.userId}`); + } +} \ No newline at end of file diff --git a/src/websocket/handlers/folders.ts b/src/websocket/handlers/folders.ts new file mode 100644 index 0000000..76039dc --- /dev/null +++ b/src/websocket/handlers/folders.ts @@ -0,0 +1,45 @@ +import { AuthenticatedWebSocket, WebSocketMessage } from '../types'; +import { ConnectionManager } from '../middleware/connection-manager'; +import { BaseResourceHandler } from './base'; + +export class FolderHandler extends BaseResourceHandler { + constructor(connectionManager: ConnectionManager) { + super(connectionManager); + } + + async handleFolderCreated(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + return this.handleResourceOperation(ws, message, { + resourceType: 'folder', + operation: 'created', + idField: 'folderId', + dataField: 'folderData', + requiresAuth: false, + syncMessageType: 'folder_created_sync', + logAction: `created folder ${message.folderData?.id}` + }); + } + + async handleFolderUpdated(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + return this.handleResourceOperation(ws, message, { + resourceType: 'folder', + operation: 'updated', + idField: 'folderId', + requiresAuth: true, + tableName: 'folders', + syncMessageType: 'folder_updated_sync', + logAction: `updated folder ${message.folderId}` + }); + } + + async handleFolderDeleted(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + return this.handleResourceOperation(ws, message, { + resourceType: 'folder', + operation: 'deleted', + idField: 'folderId', + requiresAuth: true, + tableName: 'folders', + syncMessageType: 'folder_deleted_sync', + logAction: `deleted folder ${message.folderId}` + }); + } +} \ No newline at end of file diff --git a/src/websocket/handlers/notes.ts b/src/websocket/handlers/notes.ts new file mode 100644 index 0000000..e22e60e --- /dev/null +++ b/src/websocket/handlers/notes.ts @@ -0,0 +1,177 @@ +import { db, notes } from "../../db"; +import { eq, and } from "drizzle-orm"; +import { AuthenticatedWebSocket, WebSocketMessage } from '../types'; +import { ConnectionManager } from '../middleware/connection-manager'; +import { BaseResourceHandler } from './base'; + +export class NoteHandler extends BaseResourceHandler { + constructor(connectionManager: ConnectionManager) { + super(connectionManager); + } + + async handleJoinNote(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + if (!message.noteId || !ws.userId) { + ws.send(JSON.stringify({ + type: "error", + message: "Missing noteId or userId" + })); + return; + } + + try { + // Verify the user owns this note before allowing them to join + const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, message.noteId), eq(notes.userId, ws.userId)), + }); + + if (!existingNote) { + ws.send(JSON.stringify({ + type: "error", + message: "Note not found or access denied" + })); + return; + } + + console.log(`User ${ws.userId} joined note ${message.noteId}`); + + // Track this connection for the specific note + this._connectionManager.addNoteConnection(message.noteId, ws); + + ws.send(JSON.stringify({ + type: "note_joined", + noteId: message.noteId, + message: "Successfully joined note for real-time sync" + })); + } catch (error) { + console.error("Error joining note:", error); + ws.send(JSON.stringify({ + type: "error", + message: "Failed to join note" + })); + } + } + + handleLeaveNote(ws: AuthenticatedWebSocket, message: WebSocketMessage): void { + if (!message.noteId) { + return; + } + + console.log(`User ${ws.userId} left note ${message.noteId}`); + + // Remove connection from note tracking + this._connectionManager.removeNoteConnection(message.noteId, ws); + + ws.send(JSON.stringify({ + type: "note_left", + noteId: message.noteId + })); + } + + async handleNoteUpdate(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + if (!ws.userId || !message.noteId) { + ws.send(JSON.stringify({ + type: "error", + message: "Missing userId or noteId" + })); + return; + } + + try { + // Verify the user owns this note + const existingNote = await db.query.notes.findFirst({ + where: and(eq(notes.id, message.noteId), eq(notes.userId, ws.userId)), + }); + + if (!existingNote) { + ws.send(JSON.stringify({ + type: "error", + message: "Note not found or access denied" + })); + return; + } + + // Apply the changes to the database + if (message.changes && Object.keys(message.changes).length > 0) { + const allowedFields = ['title', 'content', 'encryptedTitle', 'encryptedContent', 'starred', 'archived', 'deleted', 'hidden', 'folderId']; + const filteredChanges: Record = {}; + + Object.keys(message.changes).forEach(key => { + if (allowedFields.includes(key)) { + filteredChanges[key] = (message.changes as Record)[key]; + } else { + console.warn(`Note update: filtered out disallowed field '${key}' for note ${message.noteId}`); + } + }); + + if (Object.keys(filteredChanges).length > 0) { + console.log(`Note update: applying changes to note ${message.noteId}:`, filteredChanges); + filteredChanges.updatedAt = new Date(); + + const [updatedNote] = await db + .update(notes) + .set(filteredChanges) + .where(eq(notes.id, message.noteId)) + .returning(); + + console.log(`Note ${message.noteId} updated by user ${ws.userId}`); + + // Broadcast the successful update to all user devices + const syncMessage = { + type: "note_sync", + noteId: message.noteId, + changes: filteredChanges, + updatedNote, + timestamp: Date.now(), + fromUserId: ws.userId + }; + + const sentCount = this._connectionManager.broadcastToUserDevices(ws.userId, syncMessage, ws); + console.log(`Broadcasted message to ${sentCount} devices for user ${ws.userId}`); + + // Send confirmation to the originating device + ws.send(JSON.stringify({ + type: "note_update_success", + noteId: message.noteId, + updatedNote, + timestamp: Date.now() + })); + } else { + console.warn(`Note update: no valid changes found for note ${message.noteId}, original changes:`, message.changes); + ws.send(JSON.stringify({ + type: "error", + message: "No valid fields to update" + })); + } + } + } catch (error) { + console.error("Error handling note update:", error); + ws.send(JSON.stringify({ + type: "error", + message: "Failed to update note" + })); + } + } + + async handleNoteCreated(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + return this.handleResourceOperation(ws, message, { + resourceType: 'note', + operation: 'created', + idField: 'noteId', + dataField: 'noteData', + requiresAuth: false, + syncMessageType: 'note_created_sync', + logAction: `created note ${message.noteData?.id}` + }); + } + + async handleNoteDeleted(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + return this.handleResourceOperation(ws, message, { + resourceType: 'note', + operation: 'deleted', + idField: 'noteId', + tableName: 'notes', + syncMessageType: 'note_deleted_sync', + logAction: `deleted note ${message.noteId}` + }); + } +} \ No newline at end of file diff --git a/src/websocket/index.ts b/src/websocket/index.ts new file mode 100644 index 0000000..b67ade6 --- /dev/null +++ b/src/websocket/index.ts @@ -0,0 +1,266 @@ +import { WebSocketServer } from "ws"; +import { Server } from "http"; +import { AuthenticatedWebSocket, WebSocketMessage, WebSocketConfig, ConnectionStats } from './types'; +import { RateLimiter } from './middleware/rate-limiter'; +import { ConnectionManager } from './middleware/connection-manager'; +import { AuthHandler } from './auth/handler'; +import { NoteHandler } from './handlers/notes'; +import { FolderHandler } from './handlers/folders'; + +export class WebSocketManager { + private wss: WebSocketServer; + private config: WebSocketConfig; + private rateLimiter: RateLimiter; + private connectionManager: ConnectionManager; + private authHandler: AuthHandler; + private noteHandler: NoteHandler; + private folderHandler: FolderHandler; + private static instance: WebSocketManager | null = null; + + constructor(server: Server) { + this.config = { + rateLimitWindowMs: parseInt(process.env.WS_RATE_LIMIT_WINDOW_MS || "60000"), + rateLimitMaxMessages: parseInt(process.env.WS_RATE_LIMIT_MAX_MESSAGES || "300"), // Increased from 60 to 300 + maxConnectionsPerUser: parseInt(process.env.WS_MAX_CONNECTIONS_PER_USER || "20"), // Increased from 10 to 20 + authTimeoutMs: parseInt(process.env.WS_AUTH_TIMEOUT_MS || "30000") + }; + + this.rateLimiter = new RateLimiter(this.config); + this.connectionManager = new ConnectionManager(this.config); + this.authHandler = new AuthHandler(this.connectionManager, this.config); + this.noteHandler = new NoteHandler(this.connectionManager); + this.folderHandler = new FolderHandler(this.connectionManager); + + this.wss = new WebSocketServer({ server }); + this.setupWebSocketServer(); + WebSocketManager.instance = this; + } + + public static getInstance(): WebSocketManager | null { + return WebSocketManager.instance; + } + + private setupWebSocketServer(): void { + this.wss.on("connection", (ws: AuthenticatedWebSocket) => { + console.log("New WebSocket connection established"); + + // Set authentication timeout + this.authHandler.setupAuthTimeout(ws); + + ws.on("message", async (data: Buffer): Promise => { + try { + // Message size validation (prevent DoS attacks) + const maxMessageSize = 1024 * 1024; // 1MB limit + if (data.length > maxMessageSize) { + ws.send(JSON.stringify({ + type: "error", + message: "Message too large. Maximum size is 1MB." + })); + console.warn(`WebSocket message too large: ${data.length} bytes from user ${ws.userId || 'unauthenticated'}`); + return; + } + + // Rate limiting check + if (!this.rateLimiter.checkRateLimit(ws)) { + ws.send(JSON.stringify({ + type: "error", + message: "Rate limit exceeded. Please slow down." + })); + return; + } + + const rawMessage = JSON.parse(data.toString()); + + // Process message with optional authentication verification + const message = await this.authHandler.processIncomingMessage(ws, rawMessage); + + if (message === null) { + ws.send(JSON.stringify({ + type: "error", + message: "Message authentication failed" + })); + return; + } + + await this.handleMessage(ws, message); + } catch (error) { + console.error("Error handling WebSocket message:", error); + ws.send(JSON.stringify({ + type: "error", + message: "Invalid message format" + })); + } + }); + + ws.on("close", (): void => { + this.handleDisconnection(ws); + }); + + ws.on("error", (error: Error): void => { + console.error("WebSocket error:", error); + }); + + ws.send(JSON.stringify({ + type: "connection_established", + message: "Please authenticate to continue" + })); + }); + } + + private async handleMessage(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { + switch (message.type) { + case "auth": + await this.authHandler.handleAuthentication(ws, message); + break; + case "note_update": + if (ws.isAuthenticated) { + await this.noteHandler.handleNoteUpdate(ws, message); + } else { + ws.send(JSON.stringify({ + type: "error", + message: "Authentication required" + })); + } + break; + case "join_note": + if (ws.isAuthenticated) { + await this.noteHandler.handleJoinNote(ws, message); + } + break; + case "leave_note": + if (ws.isAuthenticated) { + this.noteHandler.handleLeaveNote(ws, message); + } + break; + case "note_created": + if (ws.isAuthenticated) { + await this.noteHandler.handleNoteCreated(ws, message); + } + break; + case "note_deleted": + if (ws.isAuthenticated) { + await this.noteHandler.handleNoteDeleted(ws, message); + } + break; + case "folder_created": + if (ws.isAuthenticated) { + await this.folderHandler.handleFolderCreated(ws, message); + } + break; + case "folder_updated": + if (ws.isAuthenticated) { + await this.folderHandler.handleFolderUpdated(ws, message); + } + break; + case "folder_deleted": + if (ws.isAuthenticated) { + await this.folderHandler.handleFolderDeleted(ws, message); + } + break; + case "ping": + ws.send(JSON.stringify({ + type: "pong", + timestamp: Date.now() + })); + break; + default: + ws.send(JSON.stringify({ + type: "error", + message: "Unknown message type" + })); + } + } + + private handleDisconnection(ws: AuthenticatedWebSocket): void { + this.connectionManager.cleanupConnection(ws); + + if (ws.userId) { + console.log(`User ${ws.userId} disconnected from WebSocket`); + } + } + + public getConnectionStats(): ConnectionStats { + return this.connectionManager.getConnectionStats(this.wss.clients.size); + } + + // Public methods for server-triggered notifications + public notifyNoteUpdate(userId: string, noteId: string, changes: Record, updatedNote: Record): void { + const syncMessage = { + type: "note_sync", + noteId, + changes, + updatedNote, + timestamp: Date.now(), + fromUserId: "server" + }; + + const sentCount = this.connectionManager.broadcastToUserDevices(userId, syncMessage); + console.log(`Server notified ${sentCount} devices about note ${noteId} update for user ${userId}`); + } + + public notifyNoteCreated(userId: string, noteData: Record): void { + const createMessage = { + type: "note_created_sync", + noteData, + timestamp: Date.now(), + fromUserId: "server" + }; + + const sentCount = this.connectionManager.broadcastToUserDevices(userId, createMessage); + console.log(`Server notified ${sentCount} devices about new note ${noteData.id} for user ${userId}`); + } + + public notifyNoteDeleted(userId: string, noteId: string): void { + const deleteMessage = { + type: "note_deleted_sync", + noteId, + timestamp: Date.now(), + fromUserId: "server" + }; + + const sentCount = this.connectionManager.broadcastToUserDevices(userId, deleteMessage); + console.log(`Server notified ${sentCount} devices about note ${noteId} deletion for user ${userId}`); + } + + public notifyFolderCreated(userId: string, folderData: Record): void { + const createMessage = { + type: "folder_created_sync", + folderData, + timestamp: Date.now(), + fromUserId: "server" + }; + + const sentCount = this.connectionManager.broadcastToUserDevices(userId, createMessage); + console.log(`Server notified ${sentCount} devices about new folder ${folderData.id} for user ${userId}`); + } + + public notifyFolderUpdated(userId: string, folderId: string, changes: Record, updatedFolder: Record): void { + const updateMessage = { + type: "folder_updated_sync", + folderId, + changes, + updatedFolder, + timestamp: Date.now(), + fromUserId: "server" + }; + + const sentCount = this.connectionManager.broadcastToUserDevices(userId, updateMessage); + console.log(`Server notified ${sentCount} devices about folder ${folderId} update for user ${userId}`); + } + + public notifyFolderDeleted(userId: string, folderId: string): void { + const deleteMessage = { + type: "folder_deleted_sync", + folderId, + timestamp: Date.now(), + fromUserId: "server" + }; + + const sentCount = this.connectionManager.broadcastToUserDevices(userId, deleteMessage); + console.log(`Server notified ${sentCount} devices about folder ${folderId} deletion for user ${userId}`); + } +} + +// Export the class and types for external use +export * from './types'; +export { WebSocketManager as default }; \ No newline at end of file diff --git a/src/websocket/middleware/connection-manager.ts b/src/websocket/middleware/connection-manager.ts new file mode 100644 index 0000000..01b8656 --- /dev/null +++ b/src/websocket/middleware/connection-manager.ts @@ -0,0 +1,110 @@ +import { WebSocket } from 'ws'; +import { AuthenticatedWebSocket, WebSocketConfig, ConnectionStats } from '../types'; + +export class ConnectionManager { + private userConnections = new Map>(); + private noteConnections = new Map>(); + + constructor(private readonly _config: WebSocketConfig) {} + + addUserConnection(userId: string, ws: AuthenticatedWebSocket): void { + if (!this.userConnections.has(userId)) { + this.userConnections.set(userId, new Set()); + } + this.userConnections.get(userId)!.add(ws); + } + + removeUserConnection(userId: string, ws: AuthenticatedWebSocket): void { + const connections = this.userConnections.get(userId); + if (connections) { + connections.delete(ws); + if (connections.size === 0) { + this.userConnections.delete(userId); + } + } + } + + addNoteConnection(noteId: string, ws: AuthenticatedWebSocket): void { + if (!this.noteConnections.has(noteId)) { + this.noteConnections.set(noteId, new Set()); + } + this.noteConnections.get(noteId)!.add(ws); + } + + removeNoteConnection(noteId: string, ws: AuthenticatedWebSocket): void { + const noteConnections = this.noteConnections.get(noteId); + if (noteConnections) { + noteConnections.delete(ws); + if (noteConnections.size === 0) { + this.noteConnections.delete(noteId); + } + } + } + + checkConnectionLimit(userId: string): boolean { + const userConnections = this.userConnections.get(userId); + if (!userConnections) { + return true; + } + + // Clean up closed connections first + const activeConnections = Array.from(userConnections).filter( + conn => conn.readyState === WebSocket.OPEN + ); + + // Update the set with only active connections + if (activeConnections.length !== userConnections.size) { + this.userConnections.set(userId, new Set(activeConnections)); + } + + return activeConnections.length < this._config.maxConnectionsPerUser; + } + + broadcastToUserDevices(userId: string, message: Record, excludeWs?: AuthenticatedWebSocket): number { + const connections = this.userConnections.get(userId); + if (!connections) return 0; + + let sentCount = 0; + connections.forEach(conn => { + if (conn !== excludeWs && conn.readyState === WebSocket.OPEN) { + conn.send(JSON.stringify(message)); + sentCount++; + } + }); + + return sentCount; + } + + cleanupConnection(ws: AuthenticatedWebSocket): void { + // Clear authentication timeout if still active + if (ws.authTimeout) { + clearTimeout(ws.authTimeout); + ws.authTimeout = undefined; + } + + if (ws.userId) { + this.removeUserConnection(ws.userId, ws); + + // Clean up note connections + this.noteConnections.forEach((noteConnections, noteId) => { + if (noteConnections.has(ws)) { + noteConnections.delete(ws); + if (noteConnections.size === 0) { + this.noteConnections.delete(noteId); + } + } + }); + } + } + + getConnectionStats(totalConnections: number): ConnectionStats { + return { + totalConnections, + authenticatedUsers: this.userConnections.size, + connectionsPerUser: Array.from(this.userConnections.entries()).map(([userId, connections]) => ({ + userId, + deviceCount: connections.size + })) + }; + } +} \ No newline at end of file diff --git a/src/websocket/middleware/rate-limiter.ts b/src/websocket/middleware/rate-limiter.ts new file mode 100644 index 0000000..fbb2c1c --- /dev/null +++ b/src/websocket/middleware/rate-limiter.ts @@ -0,0 +1,37 @@ +import { AuthenticatedWebSocket, WebSocketConfig } from '../types'; + +export class RateLimiter { + constructor(private readonly _config: WebSocketConfig) {} + + checkRateLimit(ws: AuthenticatedWebSocket): boolean { + const now = Date.now(); + + // Initialize rate limiting if not present + if (!ws.rateLimit) { + ws.rateLimit = { + count: 1, + windowStart: now + }; + return true; + } + + // Check if we need to reset the window (atomic check and reset) + const windowElapsed = now - ws.rateLimit.windowStart; + if (windowElapsed >= this._config.rateLimitWindowMs) { + ws.rateLimit = { + count: 1, + windowStart: now + }; + return true; + } + + // Check if within rate limit + if (ws.rateLimit.count >= this._config.rateLimitMaxMessages) { + return false; + } + + // Increment counter atomically + ws.rateLimit.count++; + return true; + } +} \ No newline at end of file diff --git a/src/websocket/types.ts b/src/websocket/types.ts new file mode 100644 index 0000000..bdbf993 --- /dev/null +++ b/src/websocket/types.ts @@ -0,0 +1,55 @@ +import { WebSocket } from 'ws'; + +export interface RateLimitInfo { + count: number; + windowStart: number; +} + +export interface AuthenticatedWebSocket extends WebSocket { + userId?: string; + isAuthenticated?: boolean; + rateLimit?: RateLimitInfo; + authTimeout?: ReturnType; + sessionSecret?: string; + jwtToken?: string; +} + +export interface WebSocketMessage { + type: string; + token?: string; + noteId?: string; + folderId?: string; + noteData?: Record; + folderData?: Record; + changes?: Record; + updatedNote?: Record; + updatedFolder?: Record; + [key: string]: unknown; +} + +export interface ResourceOperationConfig { + resourceType: 'folder' | 'note'; + operation: 'created' | 'updated' | 'deleted'; + idField: string; + dataField?: string; + requiresAuth?: boolean; + tableName?: 'folders' | 'notes'; + syncMessageType: string; + logAction: string; +} + +export interface ConnectionStats { + totalConnections: number; + authenticatedUsers: number; + connectionsPerUser: Array<{ + userId: string; + deviceCount: number; + }>; +} + +export interface WebSocketConfig { + rateLimitWindowMs: number; + rateLimitMaxMessages: number; + maxConnectionsPerUser: number; + authTimeoutMs: number; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e505a83..b910576 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,8 @@ "resolveJsonModule": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, + "downlevelIteration": true, + "lib": ["ES2022", "DOM"], "types": ["node"], "incremental": false, "skipDefaultLibCheck": true,