fix: resolve HTTP server URL handling and security issues (#41, #42)

- Add intelligent URL detection supporting BASE_URL, PUBLIC_URL, and proxy headers
- Fix hardcoded localhost URLs in server console output
- Add hostname validation to prevent host header injection attacks
- Restrict URL schemes to http/https only (block javascript:, file://, etc.)
- Remove sensitive environment data from API responses
- Add GET endpoints (/, /mcp) for better API discovery
- Fix version inconsistency between server implementations
- Update HTTP bridge to use HOST/PORT environment variables
- Add comprehensive test scripts for URL configuration and security

This resolves issues #41 and #42 by making the HTTP server properly handle
deployment behind reverse proxies and adds critical security validations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2025-07-15 16:46:30 +02:00
parent 4c217088f5
commit a0f09fba28
12 changed files with 641 additions and 8 deletions

View File

@@ -11,6 +11,8 @@ import { ConsoleManager } from './utils/console-manager';
import { logger } from './utils/logger';
import { readFileSync } from 'fs';
import dotenv from 'dotenv';
import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector';
import { PROJECT_VERSION } from './utils/version';
dotenv.config();
@@ -230,12 +232,44 @@ export class SingleSessionHTTPServer {
next();
});
// Root endpoint with API information
app.get('/', (req, res) => {
const port = parseInt(process.env.PORT || '3000');
const host = process.env.HOST || '0.0.0.0';
const baseUrl = detectBaseUrl(req, host, port);
const endpoints = formatEndpointUrls(baseUrl);
res.json({
name: 'n8n Documentation MCP Server',
version: PROJECT_VERSION,
description: 'Model Context Protocol server providing comprehensive n8n node documentation and workflow management',
endpoints: {
health: {
url: endpoints.health,
method: 'GET',
description: 'Health check and status information'
},
mcp: {
url: endpoints.mcp,
method: 'GET/POST',
description: 'MCP endpoint - GET for info, POST for JSON-RPC'
}
},
authentication: {
type: 'Bearer Token',
header: 'Authorization: Bearer <token>',
required_for: ['POST /mcp']
},
documentation: 'https://github.com/czlonkowski/n8n-mcp'
});
});
// Health check endpoint (no body parsing needed for GET)
app.get('/health', (req, res) => {
res.json({
status: 'ok',
mode: 'single-session',
version: '2.3.2',
version: PROJECT_VERSION,
uptime: Math.floor(process.uptime()),
sessionActive: !!this.session,
sessionAge: this.session
@@ -250,6 +284,35 @@ export class SingleSessionHTTPServer {
});
});
// MCP information endpoint (no auth required for discovery)
app.get('/mcp', (req, res) => {
res.json({
description: 'n8n Documentation MCP Server',
version: PROJECT_VERSION,
endpoints: {
mcp: {
method: 'POST',
path: '/mcp',
description: 'Main MCP JSON-RPC endpoint',
authentication: 'Bearer token required'
},
health: {
method: 'GET',
path: '/health',
description: 'Health check endpoint',
authentication: 'None'
},
root: {
method: 'GET',
path: '/',
description: 'API information',
authentication: 'None'
}
},
documentation: 'https://github.com/czlonkowski/n8n-mcp'
});
});
// Main MCP endpoint with authentication
app.post('/mcp', async (req: express.Request, res: express.Response): Promise<void> => {
// Enhanced authentication check with specific logging
@@ -347,10 +410,21 @@ export class SingleSessionHTTPServer {
this.expressServer = app.listen(port, host, () => {
logger.info(`n8n MCP Single-Session HTTP Server started`, { port, host });
// Detect the base URL using our utility
const baseUrl = getStartupBaseUrl(host, port);
const endpoints = formatEndpointUrls(baseUrl);
console.log(`n8n MCP Single-Session HTTP Server running on ${host}:${port}`);
console.log(`Health check: http://localhost:${port}/health`);
console.log(`MCP endpoint: http://localhost:${port}/mcp`);
console.log(`Health check: ${endpoints.health}`);
console.log(`MCP endpoint: ${endpoints.mcp}`);
console.log('\nPress Ctrl+C to stop the server');
if (process.env.BASE_URL || process.env.PUBLIC_URL) {
console.log(`\nPublic URL configured: ${baseUrl}`);
} else if (process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) {
console.log(`\nNote: TRUST_PROXY is enabled. URLs will be auto-detected from proxy headers.`);
}
});
// Handle server errors

View File

@@ -13,6 +13,7 @@ import { PROJECT_VERSION } from './utils/version';
import { isN8nApiConfigured } from './config/n8n-api';
import dotenv from 'dotenv';
import { readFileSync } from 'fs';
import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector';
dotenv.config();
@@ -145,6 +146,38 @@ export async function startFixedHTTPServer() {
const mcpServer = new N8NDocumentationMCPServer();
logger.info('Created persistent MCP server instance');
// Root endpoint with API information
app.get('/', (req, res) => {
const port = parseInt(process.env.PORT || '3000');
const host = process.env.HOST || '0.0.0.0';
const baseUrl = detectBaseUrl(req, host, port);
const endpoints = formatEndpointUrls(baseUrl);
res.json({
name: 'n8n Documentation MCP Server',
version: PROJECT_VERSION,
description: 'Model Context Protocol server providing comprehensive n8n node documentation and workflow management',
endpoints: {
health: {
url: endpoints.health,
method: 'GET',
description: 'Health check and status information'
},
mcp: {
url: endpoints.mcp,
method: 'GET/POST',
description: 'MCP endpoint - GET for info, POST for JSON-RPC'
}
},
authentication: {
type: 'Bearer Token',
header: 'Authorization: Bearer <token>',
required_for: ['POST /mcp']
},
documentation: 'https://github.com/czlonkowski/n8n-mcp'
});
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
@@ -181,6 +214,35 @@ export async function startFixedHTTPServer() {
}
});
// MCP information endpoint (no auth required for discovery)
app.get('/mcp', (req, res) => {
res.json({
description: 'n8n Documentation MCP Server',
version: PROJECT_VERSION,
endpoints: {
mcp: {
method: 'POST',
path: '/mcp',
description: 'Main MCP JSON-RPC endpoint',
authentication: 'Bearer token required'
},
health: {
method: 'GET',
path: '/health',
description: 'Health check endpoint',
authentication: 'None'
},
root: {
method: 'GET',
path: '/',
description: 'API information',
authentication: 'None'
}
},
documentation: 'https://github.com/czlonkowski/n8n-mcp'
});
});
// Main MCP endpoint - handle each request with custom transport handling
app.post('/mcp', async (req: express.Request, res: express.Response): Promise<void> => {
const startTime = Date.now();
@@ -414,10 +476,21 @@ export async function startFixedHTTPServer() {
expressServer = app.listen(port, host, () => {
logger.info(`n8n MCP Fixed HTTP Server started`, { port, host });
// Detect the base URL using our utility
const baseUrl = getStartupBaseUrl(host, port);
const endpoints = formatEndpointUrls(baseUrl);
console.log(`n8n MCP Fixed HTTP Server running on ${host}:${port}`);
console.log(`Health check: http://localhost:${port}/health`);
console.log(`MCP endpoint: http://localhost:${port}/mcp`);
console.log(`Health check: ${endpoints.health}`);
console.log(`MCP endpoint: ${endpoints.mcp}`);
console.log('\nPress Ctrl+C to stop the server');
if (process.env.BASE_URL || process.env.PUBLIC_URL) {
console.log(`\nPublic URL configured: ${baseUrl}`);
} else if (process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) {
console.log(`\nNote: TRUST_PROXY is enabled. URLs will be auto-detected from proxy headers.`);
}
});
// Handle errors

111
src/utils/url-detector.ts Normal file
View File

@@ -0,0 +1,111 @@
import { Request } from 'express';
import { logger } from './logger';
/**
* Validates a hostname to prevent header injection attacks
*/
function isValidHostname(host: string): boolean {
// Allow alphanumeric, dots, hyphens, and optional port
return /^[a-zA-Z0-9.-]+(:[0-9]+)?$/.test(host) && host.length < 256;
}
/**
* Validates a URL string
*/
function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Only allow http and https protocols
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
/**
* Detects the base URL for the server, considering:
* 1. Explicitly configured BASE_URL or PUBLIC_URL
* 2. Proxy headers (X-Forwarded-Proto, X-Forwarded-Host)
* 3. Host and port configuration
*/
export function detectBaseUrl(req: Request | null, host: string, port: number): string {
try {
// 1. Check for explicitly configured URL
const configuredUrl = process.env.BASE_URL || process.env.PUBLIC_URL;
if (configuredUrl) {
if (isValidUrl(configuredUrl)) {
logger.debug('Using configured BASE_URL/PUBLIC_URL', { url: configuredUrl });
return configuredUrl.replace(/\/$/, ''); // Remove trailing slash
} else {
logger.warn('Invalid BASE_URL/PUBLIC_URL configured, falling back to auto-detection', { url: configuredUrl });
}
}
// 2. If we have a request, try to detect from proxy headers
if (req && process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) {
const proto = req.get('X-Forwarded-Proto') || req.protocol || 'http';
const forwardedHost = req.get('X-Forwarded-Host');
const hostHeader = req.get('Host');
const detectedHost = forwardedHost || hostHeader;
if (detectedHost && isValidHostname(detectedHost)) {
const baseUrl = `${proto}://${detectedHost}`;
logger.debug('Detected URL from proxy headers', {
proto,
forwardedHost,
hostHeader,
baseUrl
});
return baseUrl;
} else if (detectedHost) {
logger.warn('Invalid hostname detected in proxy headers, using fallback', { detectedHost });
}
}
// 3. Fall back to configured host and port
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
const protocol = 'http'; // Default to http for local bindings
// Don't show standard ports (for http only in this fallback case)
const needsPort = port !== 80;
const baseUrl = needsPort ?
`${protocol}://${displayHost}:${port}` :
`${protocol}://${displayHost}`;
logger.debug('Using fallback URL from host/port', {
host,
displayHost,
port,
baseUrl
});
return baseUrl;
} catch (error) {
logger.error('Error detecting base URL, using fallback', error);
// Safe fallback
return `http://localhost:${port}`;
}
}
/**
* Gets the base URL for console display during startup
* This is used when we don't have a request object yet
*/
export function getStartupBaseUrl(host: string, port: number): string {
return detectBaseUrl(null, host, port);
}
/**
* Formats endpoint URLs for display
*/
export function formatEndpointUrls(baseUrl: string): {
health: string;
mcp: string;
root: string;
} {
return {
health: `${baseUrl}/health`,
mcp: `${baseUrl}/mcp`,
root: baseUrl
};
}