feat(security): harden backend against abuse with rate limiting, quota guard, delete tokens, and secure downloads

This commit is contained in:
katR 2026-09-12 18:12:27 +05:30
parent 0c3deb3f6f
commit 8d71645e35
3 changed files with 260 additions and 51 deletions

View file

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

View file

@ -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 = () => {

233
server.js
View file

@ -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', () => {