open-meteo-mcp-server
Version:
Model Context Protocol server for Open-Meteo weather APIs
197 lines • 7.38 kB
JavaScript
import rateLimit from 'express-rate-limit';
/**
* Generates a cryptographically secure session ID using the Web Crypto API
* (built-in since Node.js 14.17). Never use Math.random() for session IDs.
*/
export function generateSessionId() {
return crypto.randomUUID();
}
/**
* Express middleware that enforces API key authentication when the API_KEY
* environment variable is set. When API_KEY is not configured, all requests
* are allowed through (development/local mode).
*
* Accepts the key via:
* - Authorization: Bearer <key>
* - X-API-Key: <key>
*/
export function createAuthMiddleware() {
return (req, res, next) => {
const apiKey = process.env.API_KEY;
// No API_KEY configured → open mode (local / dev)
if (!apiKey) {
next();
return;
}
const bearer = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: undefined;
const headerKey = req.headers['x-api-key'];
if (bearer === apiKey || headerKey === apiKey) {
next();
return;
}
res.status(401).json({ error: 'Unauthorized: valid API key required' });
};
}
/**
* Express middleware guarding against DNS rebinding attacks: without it, a page
* served from any website can drive a locally bound MCP server through the
* victim's browser.
*
* Requests carrying no `Origin` header — CLI clients, SDK transports, container
* probes — pass through untouched. A request that does carry one is browser-issued
* and must match the ALLOWED_ORIGINS allow-list (comma-separated), which is empty
* by default: no browser is expected to talk to this server unless configured.
*/
export function createOriginValidator() {
return (req, res, next) => {
const origin = req.headers.origin;
if (!origin) {
next();
return;
}
const allowed = (process.env.ALLOWED_ORIGINS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (allowed.includes(origin)) {
next();
return;
}
res.status(403).json({
jsonrpc: '2.0',
error: { code: -32600, message: 'Forbidden: origin not allowed' },
id: null,
});
};
}
/**
* The MCP spec requires clients to accept both application/json and
* text/event-stream; clients sending `*\/*` or a single type are otherwise
* rejected with a 406. This widens the header on their behalf.
*
* Crucially it rewrites `rawHeaders` and not just `req.headers`: the SDK hands
* the request to Hono's `getRequestListener`, which rebuilds the web-standard
* Request from Node's raw header array, so mutating the parsed object alone is
* invisible to the transport.
*/
export function createAcceptNormalizer() {
const required = ['application/json', 'text/event-stream'];
return (req, _res, next) => {
const tokens = (req.headers.accept ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const present = new Set(tokens.map((value) => value.toLowerCase()));
for (const value of required) {
if (!present.has(value)) {
tokens.push(value);
present.add(value);
}
}
const merged = tokens.join(', ');
req.headers.accept = merged;
// Rebuild rawHeaders with exactly one Accept entry carrying the merged value.
const raw = req.rawHeaders;
const rebuilt = [];
for (let i = 0; i < raw.length; i += 2) {
const key = raw[i];
const value = raw[i + 1];
if (key === undefined || value === undefined)
continue;
if (key.toLowerCase() === 'accept')
continue;
rebuilt.push(key, value);
}
rebuilt.push('Accept', merged);
raw.length = 0;
raw.push(...rebuilt);
next();
};
}
/**
* Returns a safe, generic error message for HTTP responses.
* Never expose internal error details (stack traces, connection strings,
* internal hostnames) to clients.
*/
export function sanitizeErrorMessage(_err) {
return 'Internal server error';
}
// ---------------------------------------------------------------------------
// Trusted-proxy-aware IP extraction
// ---------------------------------------------------------------------------
/** Converts an IPv4 address string to a 32-bit integer. */
function ipToInt(ip) {
return ip.split('.').reduce((acc, octet) => (acc << 8) | parseInt(octet, 10), 0) >>> 0;
}
/** Returns true if `ip` matches the given CIDR (e.g. "10.0.0.0/8") or exact IP. */
function ipMatchesCidr(ip, cidr) {
if (!cidr.includes('/'))
return ip === cidr;
const [network, prefixStr] = cidr.split('/');
if (!network || prefixStr === undefined)
return false;
const prefix = parseInt(prefixStr, 10);
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
return (ipToInt(ip) & mask) === (ipToInt(network) & mask);
}
function isIpTrusted(ip, trustedList) {
return trustedList.some((cidr) => ipMatchesCidr(ip, cidr));
}
/**
* Returns the real client IP, respecting X-Forwarded-For only when the
* direct connection comes from a trusted proxy (TRUSTED_PROXIES env var).
*
* TRUSTED_PROXIES: comma-separated list of IPs or CIDR ranges.
* e.g. "10.0.0.0/8,192.168.1.1"
*
* When TRUSTED_PROXIES is not set, X-Forwarded-For is always ignored to
* prevent IP spoofing.
*/
/** Normalizes an IPv4-mapped IPv6 address (::ffff:x.x.x.x) to plain IPv4. */
function normalizeIp(ip) {
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
return mapped ? (mapped[1] ?? ip) : ip;
}
export function getClientIp(req) {
const rawSocketIp = req.ip ?? req.socket?.remoteAddress ?? 'unknown';
const socketIp = rawSocketIp === 'unknown' ? rawSocketIp : normalizeIp(rawSocketIp);
const trustedProxies = process.env.TRUSTED_PROXIES;
if (trustedProxies) {
const trustedList = trustedProxies
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (socketIp !== 'unknown' && isIpTrusted(socketIp, trustedList)) {
const xff = req.headers['x-forwarded-for'];
if (xff) {
const raw = Array.isArray(xff) ? (xff[0] ?? '') : xff;
const first = raw.split(',')[0]?.trim();
if (first)
return first;
}
}
}
return socketIp;
}
// ---------------------------------------------------------------------------
// Rate limiter
// ---------------------------------------------------------------------------
/**
* Creates an express-rate-limit middleware.
* Reads RATE_LIMIT_RPM from env (default: 60 requests per minute).
* Uses trusted-proxy-aware IP extraction for the key.
*/
export function createRateLimiter() {
const rpm = parseInt(process.env.RATE_LIMIT_RPM ?? '60', 10);
const max = Number.isFinite(rpm) && rpm > 0 ? rpm : 60;
return rateLimit({
windowMs: 60_000,
max,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => getClientIp(req),
});
}
//# sourceMappingURL=security.js.map