feat: add core security utilities for checksum-based share links

- Add HMAC-SHA256 checksum generation and validation (shareChecksum.ts)
- Add PDF magic bytes validation to prevent file spoofing (pdfValidator.ts)
- Add IP-based rate limiting for upload abuse prevention (uploadRateLimiter.ts)
- Update BillingLocation interface with shareTTL and shareFirstVisitedAt fields
- Add environment variables for share link security and TTL configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Knee Cola
2025-12-08 00:14:20 +01:00
parent 4a5195c938
commit a6ab35a959
5 changed files with 185 additions and 1 deletions

13
.env
View File

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

View File

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

52
app/lib/shareChecksum.ts Normal file
View File

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

View File

@@ -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<string, RateLimitEntry>();
/**
* 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);

View File

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