From 1458dcfdc449ad17a9a636d5ddf4e00eb2a919d6 Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Fri, 20 Jun 2025 08:20:28 -0700 Subject: [PATCH] CORS/CSP fix --- .env.example | 2 +- package-lock.json | 28 ++++++++----- package.json | 3 +- public/index.html | 17 ++++---- public/login.html | 24 ++++------- src/app.js | 48 ++++++++++++++++------ src/config/index.js | 5 ++- src/middleware/cors.js | 83 ++++++++++++++++++++++++++++++++++++++ src/middleware/security.js | 64 +++++++++++++++-------------- src/routes/auth.js | 21 +++++----- 10 files changed, 204 insertions(+), 91 deletions(-) create mode 100644 src/middleware/cors.js diff --git a/.env.example b/.env.example index 9a57b1a..f258836 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ PORT=3000 BASE_URL=http://localhost:3000/ # Node environment (default: development) -NODE_ENV=development +NODE_ENV=production ######################################### # FILE UPLOAD SETTINGS diff --git a/package-lock.json b/package-lock.json index ba76b05..c7e9256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "dotenv": "^16.0.3", "express": "^4.18.2", "express-rate-limit": "^7.1.5", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "toastify-js": "^1.12.0" }, "devDependencies": { "eslint": "^8.56.0", @@ -188,9 +189,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -356,9 +357,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -625,9 +626,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1801,6 +1802,7 @@ "version": "1.4.5-lts.2", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -2658,6 +2660,12 @@ "node": ">=8.0" } }, + "node_modules/toastify-js": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/toastify-js/-/toastify-js-1.12.0.tgz", + "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index f3717f5..5b102aa 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "dotenv": "^16.0.3", "express": "^4.18.2", "express-rate-limit": "^7.1.5", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "toastify-js": "^1.12.0" }, "devDependencies": { "eslint": "^8.56.0", diff --git a/public/index.html b/public/index.html index af953d6..9998091 100644 --- a/public/index.html +++ b/public/index.html @@ -4,12 +4,11 @@ {{SITE_TITLE}} - Simple File Upload - - - - - - + + + + +
@@ -156,7 +155,7 @@ // Remove leading slash from API path before concatenating const apiUrl = '/api/upload/init'.startsWith('/') ? '/api/upload/init'.substring(1) : '/api/upload/init'; - const response = await fetch(window.BASE_URL + apiUrl, { + const response = await fetch(apiUrl, { method: 'POST', headers, body: JSON.stringify({ @@ -219,7 +218,7 @@ const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30-second timeout per attempt - const response = await fetch(window.BASE_URL + chunkApiUrl, { + const response = await fetch(chunkApiUrl, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', @@ -395,7 +394,7 @@ const cancelApiUrlPath = `/api/upload/cancel/${this.uploadId}`; const cancelApiUrl = cancelApiUrlPath.startsWith('/') ? cancelApiUrlPath.substring(1) : cancelApiUrlPath; // No need to wait for response here, just fire and forget - fetch(window.BASE_URL + cancelApiUrl, { method: 'POST' }).catch(err => { + fetch(cancelApiUrl, { method: 'POST' }).catch(err => { console.warn(`Sending cancel request failed for upload ${this.uploadId}:`, err); }); } catch (cancelError) { diff --git a/public/login.html b/public/login.html index c5fc37a..798aa72 100644 --- a/public/login.html +++ b/public/login.html @@ -4,8 +4,8 @@ {{SITE_TITLE}} - Login - - + + -
@@ -126,10 +125,12 @@ // Handle form submission const verifyPin = async (pin) => { try { - const response = await fetch(window.BASE_URL + 'api/auth/verify-pin', { + const response = await fetch('/api/auth/verify-pin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pin }) + body: JSON.stringify({ pin }), + credentials: 'include', // Ensure cookies are sent + // redirect: 'follow' // Follow server redirects }); const data = await response.json(); @@ -212,7 +213,7 @@ }; // Check PIN length and initialize - fetch(window.BASE_URL + 'api/auth/pin-required') + fetch('/api/auth/pin-required') .then(response => { if (response.status === 429) { throw new Error('Too many attempts. Please wait before trying again.'); @@ -241,17 +242,6 @@ pinContainer.style.pointerEvents = 'none'; } }); - - document.addEventListener('DOMContentLoaded', function() { - // Rewrite asset URLs to use BASE_URL as prefix if not absolute - const baseUrl = window.BASE_URL; - document.querySelectorAll('link[rel="stylesheet"], link[rel="icon"]').forEach(link => { - const href = link.getAttribute('href'); - if (href && !href.startsWith('http') && !href.startsWith('data:') && !href.startsWith(baseUrl)) { - link.setAttribute('href', baseUrl + href.replace(/^\//, '')); - } - }); - }); \ No newline at end of file diff --git a/src/app.js b/src/app.js index dc7a9ec..3f6aabe 100644 --- a/src/app.js +++ b/src/app.js @@ -14,31 +14,59 @@ const fsPromises = require('fs').promises; const { config, validateConfig } = require('./config'); const logger = require('./utils/logger'); const { ensureDirectoryExists } = require('./utils/fileUtils'); -const { securityHeaders, requirePin } = require('./middleware/security'); +const { requirePin } = require('./middleware/security'); const { safeCompare } = require('./utils/security'); const { initUploadLimiter, pinVerifyLimiter, downloadLimiter } = require('./middleware/rateLimiter'); const { injectDemoBanner, demoMiddleware } = require('./utils/demoMode'); +const { originValidationMiddleware, getCorsOptions } = require('./middleware/cors'); // Create Express app const app = express(); +const PORT = process.env.PORT || 3000; +const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; // Add this line to trust the first proxy app.set('trust proxy', 1); // Middleware setup -app.use(cors()); +app.use(cors(getCorsOptions(BASE_URL))); app.use(cookieParser()); app.use(express.json()); -app.use(securityHeaders); +// --- AUTHENTICATION MIDDLEWARE FOR ALL PROTECTED ROUTES --- +app.use((req, res, next) => { + // List of paths that should be publicly accessible + const publicPaths = [ + '/login', + '/login.html', + '/api/auth/verify-pin', + '/api/auth/pin-required', + '/api/auth/pin-length', + '/pin-length', + '/verify-pin', + '/config.js', + '/assets/', + '/styles.css', + '/manifest.json', + '/asset-manifest.json', + '/toastify', + ]; + + // Check if the current path matches any of the public paths + if (publicPaths.some(path => req.path.startsWith(path))) { + return next(); + } + + // For all other paths, apply both origin validation and auth middleware + originValidationMiddleware(req, res, () => { + demoMiddleware(req, res, next); + }); +}); // Import routes const { router: uploadRouter } = require('./routes/upload'); const fileRoutes = require('./routes/files'); const authRoutes = require('./routes/auth'); -// Add demo middleware before your routes -app.use(demoMiddleware); - // Use routes with appropriate middleware app.use('/api/auth', pinVerifyLimiter, authRoutes); app.use('/api/upload', requirePin(config.pin), initUploadLimiter, uploadRouter); @@ -55,9 +83,6 @@ app.get('/', (req, res) => { html = html.replace(/{{SITE_TITLE}}/g, config.siteTitle); html = html.replace('{{AUTO_UPLOAD}}', config.autoUpload.toString()); html = html.replace('{{MAX_RETRIES}}', config.clientMaxRetries.toString()); - // Ensure baseUrl has a trailing slash for correct asset linking - const baseUrlWithSlash = config.baseUrl.endsWith('/') ? config.baseUrl : config.baseUrl + '/'; - html = html.replace(/{{BASE_URL}}/g, baseUrlWithSlash); html = injectDemoBanner(html); res.send(html); }); @@ -71,9 +96,6 @@ app.get('/login.html', (req, res) => { let html = fs.readFileSync(path.join(__dirname, '../public', 'login.html'), 'utf8'); html = html.replace(/{{SITE_TITLE}}/g, config.siteTitle); - // Ensure baseUrl has a trailing slash - const baseUrlWithSlash = config.baseUrl.endsWith('/') ? config.baseUrl : config.baseUrl + '/'; - html = html.replace(/{{BASE_URL}}/g, baseUrlWithSlash); html = injectDemoBanner(html); res.send(html); }); @@ -104,6 +126,8 @@ app.use((req, res, next) => { // Serve remaining static files app.use(express.static('public')); +// Serve Toastify assets under /toastify +app.use('/toastify', express.static(path.join(__dirname, '../node_modules/toastify-js/src'))); // Error handling middleware app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars diff --git a/src/config/index.js b/src/config/index.js index f3778ba..440e74e 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -46,7 +46,8 @@ const logConfig = (message, level = 'info') => { const DEFAULT_PORT = 3000; const DEFAULT_CHUNK_SIZE = 1024 * 1024 * 100; // 100MB const DEFAULT_SITE_TITLE = 'DumbDrop'; -const DEFAULT_BASE_URL = 'http://localhost:3000'; +const PORT = process.env.PORT || 3000; +const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; const DEFAULT_CLIENT_MAX_RETRIES = 5; // Default retry count const logAndReturn = (key, value, isDefault = false) => { @@ -131,7 +132,7 @@ const config = { * Base URL for the app (default: http://localhost:${PORT}) * Set via BASE_URL in .env */ - baseUrl: process.env.BASE_URL || DEFAULT_BASE_URL, + baseUrl: BASE_URL, // ===================== // ===================== diff --git a/src/middleware/cors.js b/src/middleware/cors.js new file mode 100644 index 0000000..1b09c03 --- /dev/null +++ b/src/middleware/cors.js @@ -0,0 +1,83 @@ +const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS || '*'; +const NODE_ENV = process.env.NODE_ENV || 'production'; +let allowedOrigins = []; + +function setupOrigins(baseUrl) { + allowedOrigins = [ baseUrl ]; + + if (NODE_ENV === 'development' || ALLOWED_ORIGINS === '*') allowedOrigins = '*'; + else if (ALLOWED_ORIGINS && typeof ALLOWED_ORIGINS === 'string') { + try { + const allowed = ALLOWED_ORIGINS.split(',').map(origin => origin.trim()); + allowed.forEach(origin => { + const normalizedOrigin = normalizeOrigin(origin); + if (normalizedOrigin !== baseUrl) allowedOrigins.push(normalizedOrigin); + }); + } + catch (error) { + console.error(`Error setting up ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}:`, error); + } + } + console.log("ALLOWED ORIGINS:", allowedOrigins); + return allowedOrigins; +} + +function normalizeOrigin(origin) { + if (origin) { + try { + const normalizedOrigin = new URL(origin).origin; + return normalizedOrigin; + } catch (error) { + console.error("Error parsing referer URL:", error); + throw new Error("Error parsing referer URL:", error); + } + } +} + +function validateOrigin(origin) { + if (NODE_ENV === 'development' || allowedOrigins === '*') return true; + + try { + if (origin) origin = normalizeOrigin(origin); + else { + console.warn("No origin to validate."); + return false; + } + + console.log("Validating Origin:", origin); + if (allowedOrigins.includes(origin)) { + console.log("Allowed request from origin:", origin); + return true; + } + else { + console.warn("Blocked request from origin:", origin); + return false; + } + } + catch (error) { + console.error(error); + } +} + +function originValidationMiddleware(req, res, next) { + const origin = req.headers.referer || `${req.protocol}://${req.headers.host}`; + const isOriginValid = validateOrigin(origin); + if (isOriginValid) { + next(); + } else { + res.status(403).json({ error: 'Forbidden' }); + } +} + +function getCorsOptions(baseUrl) { + const allowedOrigins = setupOrigins(baseUrl); + const corsOptions = { + origin: allowedOrigins, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Pin', 'X-Batch-Id'], + }; + return corsOptions; +} + +module.exports = { getCorsOptions, originValidationMiddleware, validateOrigin, allowedOrigins }; \ No newline at end of file diff --git a/src/middleware/security.js b/src/middleware/security.js index f015256..e307c9a 100644 --- a/src/middleware/security.js +++ b/src/middleware/security.js @@ -6,42 +6,46 @@ const { safeCompare } = require('../utils/security'); const logger = require('../utils/logger'); -const { config } = require('../config'); +const PORT = process.env.PORT || 3000; +const NODE_ENV = process.env.NODE_ENV || 'production'; +const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; +// const { config } = require('../config'); /** * Security headers middleware + * DEPRECATED */ -function securityHeaders(req, res, next) { - // Content Security Policy - let csp = - "default-src 'self'; " + - "connect-src 'self'; " + - "style-src 'self' 'unsafe-inline' cdn.jsdelivr.net; " + - "script-src 'self' 'unsafe-inline' cdn.jsdelivr.net; " + - "img-src 'self' data: blob:;"; +// function securityHeaders(req, res, next) { +// // Content Security Policy +// let csp = +// "default-src 'self'; " + +// "connect-src 'self'; " + +// "style-src 'self' 'unsafe-inline' cdn.jsdelivr.net; " + +// "script-src 'self' 'unsafe-inline' cdn.jsdelivr.net; " + +// "img-src 'self' data: blob:;"; - // If allowedIframeOrigins is set, allow those origins to embed via iframe - if (config.allowedIframeOrigins && config.allowedIframeOrigins.length > 0) { - // Remove X-Frame-Options header (do not set it) - // Add frame-ancestors directive to CSP - const frameAncestors = ["'self'", ...config.allowedIframeOrigins].join(' '); - csp += ` frame-ancestors ${frameAncestors};`; - } else { - // Default: only allow same origin if not configured - res.setHeader('X-Frame-Options', 'SAMEORIGIN'); - } +// // If allowedIframeOrigins is set, allow those origins to embed via iframe +// if (config.allowedIframeOrigins && config.allowedIframeOrigins.length > 0) { +// // Remove X-Frame-Options header (do not set it) +// // Add frame-ancestors directive to CSP +// const frameAncestors = ["'self'", ...config.allowedIframeOrigins].join(' '); +// csp += ` frame-ancestors ${frameAncestors};`; +// } else { +// // Default: only allow same origin if not configured +// res.setHeader('X-Frame-Options', 'SAMEORIGIN'); +// } - res.setHeader('Content-Security-Policy', csp); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-XSS-Protection', '1; mode=block'); +// res.setHeader('Content-Security-Policy', csp); +// res.setHeader('X-Content-Type-Options', 'nosniff'); +// res.setHeader('X-XSS-Protection', '1; mode=block'); - // Strict Transport Security (when in production) - if (process.env.NODE_ENV === 'production') { - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } +// // Strict Transport Security (when in production) +// if (process.env.NODE_ENV === 'production') { +// res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); +// } - next(); -} +// next(); +// } /** * PIN protection middleware @@ -66,7 +70,7 @@ function requirePin(PIN) { // Set cookie for subsequent requests with enhanced security const cookieOptions = { httpOnly: true, // Always enable HttpOnly - secure: req.secure || req.headers['x-forwarded-proto'] === 'https', // Enable secure flag only if the request is over HTTPS + secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'), sameSite: 'strict', path: '/', maxAge: 24 * 60 * 60 * 1000 // 24 hour expiry @@ -82,6 +86,6 @@ function requirePin(PIN) { } module.exports = { - securityHeaders, + // securityHeaders, requirePin }; \ No newline at end of file diff --git a/src/routes/auth.js b/src/routes/auth.js index 7662701..b4b7612 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -11,7 +11,9 @@ const { MAX_ATTEMPTS, LOCKOUT_DURATION } = require('../utils/security'); - +const PORT = process.env.PORT || 3000; +const NODE_ENV = process.env.NODE_ENV || 'production'; +const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; /** * Verify PIN */ @@ -22,13 +24,14 @@ router.post('/verify-pin', (req, res) => { try { // If no PIN is set in config, always return success if (!config.pin) { - res.cookie('DUMBDROP_PIN', '', { - httpOnly: true, - secure: req.secure || (process.env.NODE_ENV === 'production' && config.baseUrl.startsWith('https')), - sameSite: 'strict', - path: '/' - }); - return res.json({ success: true, error: null }); + // res.cookie('DUMBDROP_PIN', '', { + // httpOnly: true, + // secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'), + // sameSite: 'strict', + // path: '/' + // }); + res.clearCookie('DUMBDROP_PIN', { path: '/' }); + return res.json({ success: true, error: null, path: '/' }); } // Validate PIN format @@ -63,7 +66,7 @@ router.post('/verify-pin', (req, res) => { // Set secure cookie with cleaned PIN res.cookie('DUMBDROP_PIN', cleanedPin, { httpOnly: true, - secure: req.secure || (process.env.NODE_ENV === 'production' && config.baseUrl.startsWith('https')), + secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'), sameSite: 'strict', path: '/' });