feat(security): harden backend against abuse with rate limiting, quota guard, delete tokens, and secure downloads
This commit is contained in:
parent
0c3deb3f6f
commit
8d71645e35
3 changed files with 260 additions and 51 deletions
15
README.md
15
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
|
||||
|
|
|
|||
|
|
@ -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
233
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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue