Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/index.js
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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;

Expand Down
22 changes: 22 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 87 additions & 13 deletions backend/proxy.js
Original file line number Diff line number Diff line change
@@ -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 };
4 changes: 4 additions & 0 deletions vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export default defineConfig({
target: 'http://localhost:3001',
changeOrigin: true,
},
'/proxy': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
plugins: [
Expand Down
Loading