diff --git a/.env b/.env index 6364b54..49e90e0 100644 --- a/.env +++ b/.env @@ -9,4 +9,15 @@ LINKEDIN_SECRET=ugf61aJ2iyErLK40 USE_MOCK_AUTH=true MAX_BILL_ATTACHMENT_UPLOAD_SIZE_KB=1024 -MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB=1024 \ No newline at end of file +MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB=1024 + +# Share link security +SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654 + +# Share link TTL configuration +SHARE_TTL_INITIAL_DAYS=10 +SHARE_TTL_AFTER_VISIT_HOURS=1 + +# Rate limiting for uploads +UPLOAD_RATE_LIMIT_PER_IP=5 +UPLOAD_RATE_LIMIT_WINDOW_MS=3600000 \ No newline at end of file diff --git a/app/lib/db-types.ts b/app/lib/db-types.ts index 341ed62..87a93ef 100644 --- a/app/lib/db-types.ts +++ b/app/lib/db-types.ts @@ -81,6 +81,10 @@ export interface BillingLocation { utilBillsProofOfPayment?: FileAttachment|null; /** (optional) rent proof of payment attachment */ rentProofOfPayment?: FileAttachment|null; + /** (optional) share link expiry timestamp */ + shareTTL?: Date; + /** (optional) when tenant first visited the share link */ + shareFirstVisitedAt?: Date | null; }; export enum BilledTo { diff --git a/app/lib/shareChecksum.ts b/app/lib/shareChecksum.ts new file mode 100644 index 0000000..54c2eab --- /dev/null +++ b/app/lib/shareChecksum.ts @@ -0,0 +1,52 @@ +import crypto from 'crypto'; + +/** + * Generate share link checksum for location + * Uses HMAC-SHA256 for cryptographic integrity + * + * SECURITY: Prevents location ID enumeration while allowing stateless validation + */ +export function generateShareChecksum(locationId: string): string { + const secret = process.env.SHARE_LINK_SECRET; + + if (!secret) { + throw new Error('SHARE_LINK_SECRET environment variable not configured'); + } + + return crypto + .createHmac('sha256', secret) + .update(locationId) + .digest('hex') + .substring(0, 16); // 64 bits of entropy (sufficient for share links) +} + +/** + * Validate share link checksum + * Uses constant-time comparison to prevent timing attacks + * + * @param locationId - The location ID from URL + * @param providedChecksum - The checksum from URL + * @returns true if checksum is valid + */ +export function validateShareChecksum( + locationId: string, + providedChecksum: string +): boolean { + try { + const expectedChecksum = generateShareChecksum(locationId); + + // Convert to buffers for timing-safe comparison + const expected = Buffer.from(expectedChecksum); + const provided = Buffer.from(providedChecksum); + + // Length check (prevents timing attack on different lengths) + if (expected.length !== provided.length) { + return false; + } + + // Constant-time comparison (prevents timing attacks) + return crypto.timingSafeEqual(expected, provided); + } catch { + return false; + } +} diff --git a/app/lib/uploadRateLimiter.ts b/app/lib/uploadRateLimiter.ts new file mode 100644 index 0000000..62b2ba1 --- /dev/null +++ b/app/lib/uploadRateLimiter.ts @@ -0,0 +1,71 @@ +/** + * Simple in-memory rate limiter for upload attempts + * Tracks by IP address + */ + +interface RateLimitEntry { + count: number; + resetAt: number; // Unix timestamp +} + +// In-memory store (use Redis for production multi-instance setups) +const rateLimitStore = new Map(); + +/** + * Check if IP address is rate limited + * @returns { allowed: boolean, remaining: number } + */ +export function checkUploadRateLimit(ipAddress: string): { allowed: boolean; remaining: number; resetIn: number } { + + const maxUploads = parseInt(process.env.UPLOAD_RATE_LIMIT_PER_IP || '5', 10); + const windowMs = parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS || '3600000', 10); // 1 hour + + const now = Date.now(); + const key = `upload:${ipAddress}`; + + let entry = rateLimitStore.get(key); + + // Clean up expired entry or create new one + if (!entry || now > entry.resetAt) { + entry = { + count: 0, + resetAt: now + windowMs + }; + rateLimitStore.set(key, entry); + } + + // Check if limit exceeded + if (entry.count >= maxUploads) { + return { + allowed: false, + remaining: 0, + resetIn: Math.ceil((entry.resetAt - now) / 1000) // seconds + }; + } + + // Increment counter + entry.count++; + rateLimitStore.set(key, entry); + + return { + allowed: true, + remaining: maxUploads - entry.count, + resetIn: Math.ceil((entry.resetAt - now) / 1000) + }; +} + +/** + * Periodic cleanup of expired entries (prevent memory leak) + * Call this occasionally (e.g., every hour) + */ +export function cleanupRateLimitStore() { + const now = Date.now(); + for (const [key, entry] of rateLimitStore.entries()) { + if (now > entry.resetAt) { + rateLimitStore.delete(key); + } + } +} + +// Auto-cleanup every 10 minutes +setInterval(cleanupRateLimitStore, 10 * 60 * 1000); diff --git a/app/lib/validators/pdfValidator.ts b/app/lib/validators/pdfValidator.ts new file mode 100644 index 0000000..8036c4c --- /dev/null +++ b/app/lib/validators/pdfValidator.ts @@ -0,0 +1,46 @@ +/** + * Validate that uploaded file is a legitimate PDF + * Checks magic bytes, not just MIME type + */ +export async function validatePdfFile(file: File): Promise<{ valid: boolean; error?: string }> { + + // Check file size first (quick rejection) + const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10); + const maxFileSizeBytes = maxFileSizeKB * 1024; + + if (file.size === 0) { + return { valid: false, error: 'File is empty' }; + } + + if (file.size > maxFileSizeBytes) { + return { valid: false, error: `File size exceeds ${maxFileSizeKB} KB limit` }; + } + + // Read file content + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Check PDF magic bytes (header signature) + // PDF files must start with "%PDF-" (bytes: 25 50 44 46 2D) + const header = buffer.toString('utf-8', 0, 5); + if (!header.startsWith('%PDF-')) { + return { valid: false, error: 'Invalid PDF file format' }; + } + + // Optional: Check for PDF version (1.0 to 2.0) + const version = buffer.toString('utf-8', 5, 8); // e.g., "1.4", "1.7", "2.0" + const versionMatch = version.match(/^(\d+\.\d+)/); + if (!versionMatch) { + return { valid: false, error: 'Invalid PDF version' }; + } + + // Optional: Verify PDF EOF marker (should end with %%EOF) + // Note: Some PDFs have trailing data, so this is lenient + const endSection = buffer.toString('utf-8', Math.max(0, buffer.length - 1024)); + if (!endSection.includes('%%EOF')) { + console.warn('PDF missing EOF marker - may be corrupted'); + // Don't reject, just warn (some valid PDFs have non-standard endings) + } + + return { valid: true }; +}