diff --git a/backend/index.js b/backend/index.js index 6b5c8dc85f..5f6c600e2f 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,6 +1,7 @@ import { makeHandleRequest, serveStaticApp, serveMonaco } from './common'; import { handleTracking } from './tracking.js'; import jsyaml from 'js-yaml'; +import { proxyHandler, proxyRateLimiter } from './proxy.js'; import companionRouter from './companion/companionRouter'; //import { requestLogger } from './utils/other'; //uncomment this to log the outgoing traffic @@ -59,8 +60,7 @@ if (process.env.NODE_ENV === 'development') { app.use(cors({ origin: '*' })); } -// Uncomment after: https://github.com/kyma-project/busola/issues/3680 -// app.use('/proxy', proxyHandler); +app.use('/proxy', proxyRateLimiter, proxyHandler); let server = null; diff --git a/backend/package-lock.json b/backend/package-lock.json index 4dd54b393d..c451a66ea8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "compression": "^1.8.0", "cors": "^2.8.5", "express": "^4.21.2", + "express-rate-limit": "^7.5.0", "https": "^1.0.0", "jose": "^6.0.10", "js-yaml": "^4.1.0", @@ -5238,6 +5239,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/express/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -15135,6 +15151,12 @@ } } }, + "express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "requires": {} + }, "extend-shallow": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 7c23f35fd9..5960e1bfb4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "compression": "^1.8.0", "cors": "^2.8.5", "express": "^4.21.2", + "express-rate-limit": "^7.5.0", "https": "^1.0.0", "jose": "^6.0.10", "js-yaml": "^4.1.0", diff --git a/backend/proxy.js b/backend/proxy.js index ad4a35c94b..dd334dcec5 100644 --- a/backend/proxy.js +++ b/backend/proxy.js @@ -1,45 +1,119 @@ +import rateLimit from 'express-rate-limit'; import { request as httpsRequest } from 'https'; -import { request as httpRequest } from 'http'; import { URL } from 'url'; +import net from 'net'; +import dns from 'dns/promises'; + +function isLocalDomain(hostname) { + const localDomains = ['localhost', '127.0.0.1', '::1']; + const localSuffixes = ['.localhost', '.local', '.internal']; + + if (localDomains.includes(hostname.toLowerCase())) { + return true; + } + + return localSuffixes.some(suffix => hostname.endsWith(suffix)); +} + +function isValidHost(hostname) { + return !isLocalDomain(hostname) && net.isIP(hostname) === 0; +} + +function isPrivateIp(ip) { + if (net.isIPv4(ip)) { + const parts = ip.split('.').map(Number); + // 10.0.0.0/8 + if (parts[0] === 10) return true; + // 172.16.0.0/12 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; + // 192.168.0.0/16 + if (parts[0] === 192 && parts[1] === 168) return true; + // 127.0.0.0/8 (localhost) + if (parts[0] === 127) return true; + } + if (net.isIPv6(ip)) { + const lowerIp = ip.toLowerCase(); + // fc00::/7 (unique local addresses) + if (lowerIp.startsWith('fc') || lowerIp.startsWith('fd')) return true; + // fe80::/10 (link-local addresses) + if (lowerIp.startsWith('fe80:')) return true; + // ::1/128 (localhost) + if (lowerIp === '::1') return true; + } + return false; +} + +// Perform DNS resolution to check for private IPs +async function isPrivateAddress(hostname) { + try { + const addresses = await dns.lookup(hostname, { all: true }); + for (const addr of addresses) { + if (isPrivateIp(addr.address)) { + return true; + } + } + return false; + } catch (err) { + return true; + } +} + +// Rate limiter: Max 100 requests per 1 minutes per IP +const proxyRateLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, + max: 100, + message: 'Too many requests from this IP, please try again later.', + standardHeaders: true, + legacyHeaders: false, +}); async function proxyHandler(req, res) { const targetUrl = req.query.url; if (!targetUrl) { - return res.status(400).send('Target URL is required as a query parameter'); + return res.status(400).send('Bad Request'); } try { const parsedUrl = new URL(targetUrl); - const isHttps = parsedUrl.protocol === 'https:'; - const libRequest = isHttps ? httpsRequest : httpRequest; + + if (parsedUrl.protocol !== 'https:') { + return res.status(403).send('Request Forbidden'); + } + + if (isValidHost(parsedUrl.hostname)) { + return res.status(403).send('Request Forbidden'); + } + + if (await isPrivateAddress(parsedUrl.hostname)) { + return res.status(403).send('Request Forbidden'); + } const options = { hostname: parsedUrl.hostname, - port: parsedUrl.port || (isHttps ? 443 : 80), + port: parsedUrl.port || 443, path: parsedUrl.pathname + parsedUrl.search, method: req.method, headers: { ...req.headers, host: parsedUrl.host }, + timeout: 30000, }; - const proxyReq = libRequest(options, proxyRes => { - // Forward status and headers from the target response + const proxyReq = httpsRequest(options, proxyRes => { res.writeHead(proxyRes.statusCode, proxyRes.headers); - // Pipe the response data from the target back to the client proxyRes.pipe(res); }); proxyReq.on('error', () => { - res.status(500).send('An error occurred while making the proxy request.'); + res.status(502).send('An error occurred while making the proxy request.'); }); if (Buffer.isBuffer(req.body)) { - proxyReq.end(req.body); // If the body is already buffered, use it directly. + proxyReq.end(req.body); } else { - req.pipe(proxyReq); // Otherwise, pipe the request for streamed or chunked data. + req.pipe(proxyReq); } } catch (error) { - res.status(500).send('An error occurred while processing the request.'); + res.status(400).send('Bad Request'); } } -export { proxyHandler }; +export { proxyHandler, proxyRateLimiter }; diff --git a/vite.config.mts b/vite.config.mts index d056603ede..0a5494a47f 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -21,6 +21,10 @@ export default defineConfig({ target: 'http://localhost:3001', changeOrigin: true, }, + '/proxy': { + target: 'http://localhost:3001', + changeOrigin: true, + }, }, }, plugins: [