From 8d71645e354de749d7c7aa79fd14ef7e48468ba9 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 12 Sep 2026 18:12:27 +0530 Subject: [PATCH] feat(security): harden backend against abuse with rate limiting, quota guard, delete tokens, and secure downloads --- README.md | 15 +-- public/js/app.js | 63 ++++++++++--- server.js | 233 ++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 260 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 3f5bd50..65fdcf9 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,21 @@ a minimal, smooth file transfer service with 10-minute auto-deletion. - drag & drop files anywhere to upload instantly - 10-minute auto-deletion timer per file with live countdown - safe path verification and collision-free file naming -- zero external CDN dependencies (works completely offline) +- anti-abuse IP rate limiting (120 req/min, 25 uploads/5min) +- storage quota guard (4GB cap protecting disk exhaustion) +- delete token authorization (only uploader can delete their file) +- anti-XSS download isolation (`Content-Security-Policy: default-src 'none'; sandbox` + force attachment) +- strict CORS domain whitelist (`*.oiupoyt.space`, `localhost`) ## configuration -### connecting your frontend to your backend - -To connect your deployed frontend to your private backend (e.g. Cloudflare Tunnel or local server), pass your API URL via URL parameters once: +The deployed frontend at `https://katdrop.oiupoyt.space` connects out of the box to the official permanent backend at `https://katdrop-api.oiupoyt.space`. No URL parameters or configuration required. +To connect to a custom private backend, pass `?api=...`: ``` -https://katdrop.oiupoyt.space/?api=https://your-tunnel.trycloudflare.com +https://katdrop.oiupoyt.space/?api=https://your-custom-backend.com ``` -katdrop will automatically save this to `localStorage` in your browser. - ## usage ```bash diff --git a/public/js/app.js b/public/js/app.js index c105c2b..6617013 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -20,8 +20,13 @@ let selectedFiles = []; - // API Configuration (configurable via URL param ?api=... or localStorage) + // API Configuration (permanent default backend) const DEFAULT_REMOTE_API = 'https://katdrop-api.oiupoyt.space'; + // Purge any legacy ephemeral trycloudflare or placeholder URLs from localStorage + const savedApi = localStorage.getItem('katdrop_api'); + if (savedApi && (savedApi.includes('trycloudflare.com') || savedApi.includes('YOUR_BACKEND_URL'))) { + localStorage.removeItem('katdrop_api'); + } const urlParams = new URLSearchParams(window.location.search); if (urlParams.has('api')) { const customApi = urlParams.get('api').replace(/\/+$/, ''); @@ -316,20 +321,29 @@ } } - // Delete file with smooth animation + // Delete file with smooth animation (authorized via deleteToken) window.deleteFile = async function (filename, cardElement) { if (cardElement) cardElement.classList.add('deleting'); try { - const res = await apiFetch(`${API_BASE}/delete/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const delToken = sessionStorage.getItem('katdrop_del_' + filename) || ''; + const headers = {}; + if (delToken) headers['x-delete-token'] = delToken; + + const res = await apiFetch(`${API_BASE}/delete/${encodeURIComponent(filename)}`, { + method: 'DELETE', + headers + }); if (res.ok) { + sessionStorage.removeItem('katdrop_del_' + filename); showToast('file deleted'); setTimeout(() => { fetchFiles(); }, 180); } else { if (cardElement) cardElement.classList.remove('deleting'); - showToast('failed to delete'); + const err = await res.json().catch(() => null); + showToast(err && err.error ? err.error : 'failed to delete'); } } catch (err) { if (cardElement) cardElement.classList.remove('deleting'); @@ -379,17 +393,40 @@ }); xhr.onload = () => { - progressBar.style.width = '100%'; - if (progressText) progressText.textContent = 'processing...'; - setTimeout(() => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText); + if (data && data.files) { + data.files.forEach(f => { + if (f.name && f.deleteToken) { + sessionStorage.setItem('katdrop_del_' + f.name, f.deleteToken); + } + }); + } + } catch (e) {} + + progressBar.style.width = '100%'; + if (progressText) progressText.textContent = 'processing...'; + setTimeout(() => { + progressWrap.classList.remove('active'); + progressBar.style.width = '0%'; + if (progressText) progressText.textContent = ''; + fileInput.value = ''; + updateFileSelection([]); + fetchFiles(); + showToast('upload complete'); + }, 250); + } else { + let msg = 'upload failed'; + try { + const errData = JSON.parse(xhr.responseText); + if (errData && errData.error) msg = errData.error; + } catch (e) {} progressWrap.classList.remove('active'); - progressBar.style.width = '0%'; + uploadBtn.disabled = false; if (progressText) progressText.textContent = ''; - fileInput.value = ''; - updateFileSelection([]); - fetchFiles(); - showToast('upload complete'); - }, 250); + showToast(msg); + } }; xhr.onerror = () => { diff --git a/server.js b/server.js index 011e583..7d10cf7 100644 --- a/server.js +++ b/server.js @@ -2,8 +2,10 @@ const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const app = express(); +app.disable('x-powered-by'); let currentPort = parseInt(process.env.PORT, 10) || 3000; // Directories @@ -14,8 +16,24 @@ if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } -// 10-Minute File Expiration (in milliseconds) -const FILE_TTL_MS = 10 * 60 * 1000; +// Security & Quota Constants +const FILE_TTL_MS = 10 * 60 * 1000; // 10 Minutes File Expiration +const MAX_TOTAL_STORAGE_BYTES = 4 * 1024 * 1024 * 1024; // 4GB Max Storage Quota +const MAX_FILE_SIZE_BYTES = 1024 * 1024 * 1024; // 1GB Max per file + +// In-Memory Security Stores +const deleteTokens = new Map(); // filename -> deleteToken +const rateLimitMap = new Map(); // ip -> { count, resetTime } +const uploadRateLimitMap = new Map(); // ip -> { count, resetTime } + +// Utility: Client IP extraction +function getClientIp(req) { + const cf = req.headers['cf-connecting-ip']; + if (cf) return String(cf).trim(); + const xff = req.headers['x-forwarded-for']; + if (xff) return String(xff).split(',')[0].trim(); + return req.socket.remoteAddress || '127.0.0.1'; +} // Utility: format file size function formatBytes(bytes) { @@ -26,11 +44,28 @@ function formatBytes(bytes) { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } -// Utility: Safe unique filename (prevent overwrites and directory traversal) +// Utility: Total directory storage calculator +async function getTotalUploadSize() { + try { + const entries = await fs.promises.readdir(uploadDir); + let total = 0; + for (const name of entries) { + if (name.startsWith('.')) continue; + const stats = await fs.promises.stat(path.join(uploadDir, name)).catch(() => null); + if (stats && stats.isFile()) total += stats.size; + } + return total; + } catch { + return 0; + } +} + +// Utility: Safe unique filename (prevents directory traversal, collision, and shell injection) function getSafeUniqueName(destination, originalName) { - const base = path.basename(originalName).replace(/[^\w\s.-]/gi, '_').trim() || 'file'; - const ext = path.extname(base); - const nameOnly = path.basename(base, ext) || 'file'; + // Sanitize: strip dangerous chars, limit length, prevent dotfile creation + const base = path.basename(originalName).replace(/[^\w\s.-]/gi, '_').replace(/^\.+/, '').trim() || 'file'; + const ext = path.extname(base).slice(0, 16); + const nameOnly = (path.basename(base, ext) || 'file').slice(0, 120); let candidate = `${nameOnly}${ext}`; let counter = 1; @@ -45,6 +80,7 @@ function getSafeUniqueName(destination, originalName) { function getSafeFilePath(filename) { if (!filename || typeof filename !== 'string') return null; const safeName = path.basename(filename); + if (safeName.startsWith('.')) return null; // Reject hidden files const resolvedPath = path.resolve(uploadDir, safeName); if (!resolvedPath.startsWith(uploadDir + path.sep) && resolvedPath !== uploadDir) { return null; @@ -65,18 +101,27 @@ async function cleanupExpiredFiles() { const stats = await fs.promises.stat(fullPath); if (stats.isFile() && (now - stats.mtimeMs) >= FILE_TTL_MS) { await fs.promises.unlink(fullPath); + deleteTokens.delete(name); } } catch (err) { // Ignore files already deleted or locked } } + + // Clean up expired rate limiter maps + for (const [ip, entry] of rateLimitMap.entries()) { + if (now > entry.resetTime + 60000) rateLimitMap.delete(ip); + } + for (const [ip, entry] of uploadRateLimitMap.entries()) { + if (now > entry.resetTime + 60000) uploadRateLimitMap.delete(ip); + } } catch (err) { // Ignore read errors during sweep } } -// Run cleanup sweep every 10 seconds (unref'd to prevent blocking process exit) -const cleanupInterval = setInterval(cleanupExpiredFiles, 10 * 1000); +// Run cleanup sweep every 60 seconds (unref'd to prevent blocking process exit) +const cleanupInterval = setInterval(cleanupExpiredFiles, 60 * 1000); cleanupInterval.unref(); // Multer storage @@ -88,43 +133,137 @@ const storage = multer.diskStorage({ }); const upload = multer({ storage, - limits: { fileSize: 2 * 1024 * 1024 * 1024 } // 2GB + limits: { + fileSize: MAX_FILE_SIZE_BYTES, // 1GB + files: 10 // Max 10 files per upload batch + } }); -// Middlewares +// Security & Rate Limiting Middlewares +function generalRateLimiter(req, res, next) { + const ip = getClientIp(req); + const now = Date.now(); + const entry = rateLimitMap.get(ip) || { count: 0, resetTime: now + 60000 }; + if (now > entry.resetTime) { + entry.count = 0; + entry.resetTime = now + 60000; + } + entry.count++; + rateLimitMap.set(ip, entry); + if (entry.count > 120) { + return res.status(429).json({ error: 'Too many requests, please slow down.' }); + } + next(); +} + +function uploadRateLimiter(req, res, next) { + const ip = getClientIp(req); + const now = Date.now(); + const entry = uploadRateLimitMap.get(ip) || { count: 0, resetTime: now + 300000 }; + if (now > entry.resetTime) { + entry.count = 0; + entry.resetTime = now + 300000; + } + entry.count++; + uploadRateLimitMap.set(ip, entry); + if (entry.count > 25) { + return res.status(429).json({ error: 'Upload rate limit reached (max 25 uploads per 5 minutes).' }); + } + next(); +} + +// Storage Quota Guard Middleware +async function storageQuotaGuard(req, res, next) { + const currentSize = await getTotalUploadSize(); + const incomingLength = parseInt(req.headers['content-length'] || '0', 10); + if (currentSize + incomingLength > MAX_TOTAL_STORAGE_BYTES) { + return res.status(507).json({ + error: 'Storage quota full (4GB cap). Please wait for older files to expire.' + }); + } + next(); +} + +// CORS & Hardened Security Headers Middleware app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); + const origin = req.headers.origin; + if (origin) { + try { + const url = new URL(origin); + const host = url.hostname; + if ( + host === 'localhost' || + host === '127.0.0.1' || + host.endsWith('.oiupoyt.space') || + host === 'oiupoyt.space' || + host.startsWith('192.168.') || + host.startsWith('10.') || + host.startsWith('172.16.') + ) { + res.header('Access-Control-Allow-Origin', origin); + } + } catch { + // Invalid origin URL + } + } else { + res.header('Access-Control-Allow-Origin', '*'); + } + res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, x-delete-token'); + res.header('X-Content-Type-Options', 'nosniff'); + res.header('X-Frame-Options', 'SAMEORIGIN'); + res.header('Referrer-Policy', 'strict-origin-when-cross-origin'); + if (req.method === 'OPTIONS') { return res.sendStatus(200); } next(); }); + +app.use(generalRateLimiter); app.use(express.json()); -app.use(express.static(publicDir)); +app.use(express.static(publicDir, { maxAge: '1h', etag: true })); // Health check endpoint app.get('/health', (req, res) => { - res.json({ status: 'ok', uptime: Math.floor(process.uptime()) }); + res.json({ + status: 'ok', + uptime: Math.floor(process.uptime()), + timestamp: Date.now() + }); }); -// Upload endpoint (supports single or multiple files) -app.post('/upload', upload.array('file', 20), (req, res) => { - if (!req.files || req.files.length === 0) { - return res.status(400).json({ error: 'No files uploaded' }); +// Upload endpoint (supports single or multiple files with rate-limiting and quota protection) +app.post( + '/upload', + uploadRateLimiter, + storageQuotaGuard, + upload.array('file', 10), + (req, res) => { + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const now = Date.now(); + const uploaded = req.files.map(f => { + // Generate unguessable deletion token so only uploader can manually delete before 10-min TTL + const deleteToken = crypto.randomBytes(16).toString('hex'); + deleteTokens.set(f.filename, deleteToken); + + return { + name: f.filename, + size: f.size, + sizeFormatted: formatBytes(f.size), + expiresAt: new Date(now + FILE_TTL_MS).toISOString(), + remainingSeconds: Math.floor(FILE_TTL_MS / 1000), + deleteToken + }; + }); + + res.json({ success: true, files: uploaded }); } - - const now = Date.now(); - const uploaded = req.files.map(f => ({ - name: f.filename, - size: f.size, - sizeFormatted: formatBytes(f.size), - expiresAt: new Date(now + FILE_TTL_MS).toISOString(), - remainingSeconds: Math.floor(FILE_TTL_MS / 1000) - })); - res.json({ success: true, files: uploaded }); -}); +); // List files with metadata, remaining TTL, and on-demand expiration check app.get('/files', async (req, res) => { @@ -144,6 +283,7 @@ app.get('/files', async (req, res) => { // Immediate purge if expired if (ageMs >= FILE_TTL_MS) { fs.promises.unlink(fullPath).catch(() => {}); + deleteTokens.delete(name); continue; } @@ -171,7 +311,7 @@ app.get('/files', async (req, res) => { } }); -// Download / direct link with on-demand expiration check +// Download / direct link with on-demand expiration check and anti-XSS isolation app.get('/uploads/:filename', (req, res) => { const safe = getSafeFilePath(req.params.filename); if (!safe || !fs.existsSync(safe.resolvedPath)) { @@ -182,28 +322,59 @@ app.get('/uploads/:filename', (req, res) => { const stats = fs.statSync(safe.resolvedPath); if ((Date.now() - stats.mtimeMs) >= FILE_TTL_MS) { fs.unlink(safe.resolvedPath, () => {}); + deleteTokens.delete(safe.safeName); return res.status(410).send('File expired'); } } catch (err) { return res.status(404).send('File not found'); } + // Enforce Sandbox & Download attachment to prevent stored XSS from malicious SVG/HTML uploads + res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Cache-Control', 'private, no-transform'); res.download(safe.resolvedPath, safe.safeName); }); -// Delete file +// Secure Delete endpoint (requires matching delete token generated on upload) app.delete('/delete/:filename', async (req, res) => { const safe = getSafeFilePath(req.params.filename); if (!safe) return res.status(400).send('Invalid filename'); + const expectedToken = deleteTokens.get(safe.safeName); + const clientToken = req.headers['x-delete-token'] || req.query.token; + + // Verify deletion token if present in record + if (expectedToken && (!clientToken || clientToken !== expectedToken)) { + return res.status(403).json({ error: 'Unauthorized: Invalid or missing delete token' }); + } + try { await fs.promises.unlink(safe.resolvedPath); + deleteTokens.delete(safe.safeName); res.send('File deleted'); } catch (err) { res.status(500).send('Error deleting file'); } }); +// Multer error handling middleware +app.use((err, req, res, next) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: 'File exceeds 1GB limit.' }); + } + if (err.code === 'LIMIT_FILE_COUNT') { + return res.status(400).json({ error: 'Max 10 files per upload batch.' }); + } + return res.status(400).json({ error: err.message }); + } + if (err) { + return res.status(500).json({ error: 'Internal server error' }); + } + next(); +}); + // Start Server with fallback port handling function startServer(port, maxAttempts = 10) { const server = app.listen(port, '0.0.0.0', () => {