Session IP Binding
Priority: 🔵 LONG-TERM Estimated Time: 3 hours Risk Level: MEDIUM Impact: Prevent session hijacking attacks
Issue Description
Current session management:
- ✅ Uses cryptographically secure UUIDs
- ✅ Redis-backed TTL expiration
- ❌ No IP address validation
- ❌ Session can be used from any IP
Risk: If session ID is stolen, attacker can use it from anywhere.
Proposed Solution
Bind sessions to client IP address:
- Store client IP in session metadata
- Validate IP on each request
- Optional: Allow IP changes with re-authentication
- Configurable for proxy/load balancer setups
Implementation
Note: Redis is included in the local development environment when using ./scripts/local (part of the Docker stack).
Update Session Metadata
Update src/services/session-store.ts:
export interface SessionMetadata {
sessionId: string;
createdAt: number;
lastAccessedAt: number;
userId?: string;
clientIp?: string; // NEW
userAgent?: string; // NEW
fingerprint?: string; // NEW: Hash of IP + User-Agent
}Transport Creation
Update src/mcp/mcp.ts:
export async function createTransport(req: Request): Promise<StreamableHTTPServerTransport> {
const sessionStore = getSessionStore();
const clientIp = req.ip;
const userAgent = req.headers["user-agent"];
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true,
onsessioninitialized: async (sessionId: string) => {
transports[sessionId] = transport;
const now = Date.now();
await sessionStore.set(sessionId, {
createdAt: now,
lastAccessedAt: now,
clientIp, // NEW
userAgent, // NEW
fingerprint: createFingerprint(clientIp, userAgent), // NEW
});
mcpSessionsActive.inc();
mcpSessionsTotal.inc({ status: "created" });
logger.info("Session created", { sessionId, clientIp });
},
});
const server = createMcpServer();
await server.connect(transport);
return transport;
}
function createFingerprint(ip?: string, userAgent?: string): string {
const data = `${ip}:${userAgent}`;
return crypto.createHash("sha256").update(data).digest("hex");
}Request Validation
Update src/routes/mcp.ts:
mcpRouter.post("/", async (req: Request, res: Response): Promise<void> => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (sessionId) {
const transport = await getTransport(sessionId);
if (transport) {
// Validate IP binding
const sessionStore = getSessionStore();
const metadata = await sessionStore.get(sessionId);
if (metadata && config.session.ipBinding.enabled) {
const currentIp = req.ip;
const currentFingerprint = createFingerprint(
currentIp,
req.headers["user-agent"]
);
if (metadata.fingerprint !== currentFingerprint) {
logger.warn("Session fingerprint mismatch", {
sessionId,
originalIp: metadata.clientIp,
currentIp,
});
if (config.session.ipBinding.strict) {
// Reject the request
await removeTransport(sessionId);
res.status(403).json({
jsonrpc: "2.0",
error: {
code: -32001,
message: "Forbidden",
data: {
reason: "session_fingerprint_mismatch",
details: "Session cannot be used from this location",
},
},
id: null,
});
return;
} else {
// Warn but allow (for mobile/proxy scenarios)
logger.info("Allowing session with IP mismatch (non-strict mode)", {
sessionId,
});
}
}
}
await sessionStore.touch(sessionId);
await transport.handleRequest(req, res, req.body);
return;
}
}
// ... rest of handler
});Configuration
Add configuration in src/config/session.ts:
export const sessionConfig = {
ttlSeconds: parseInt(process.env.MCP_SESSION_TTL_SECONDS ?? "86400", 10),
keyPrefix: process.env.MCP_SESSION_KEY_PREFIX ?? "mcp:session:",
// IP binding configuration
ipBinding: {
enabled: process.env.SESSION_IP_BINDING_ENABLED !== "false",
strict: process.env.SESSION_IP_BINDING_STRICT === "true", // false = warn only
},
} as const;Configuration Options
Add to .env.example:
# Session IP binding
SESSION_IP_BINDING_ENABLED=true
SESSION_IP_BINDING_STRICT=false # true = reject, false = warnTesting
Create tests in src/routes/mcp.test.ts:
describe("Session IP Binding", () => {
it("should allow requests from same IP", async () => {
// Test
});
it("should reject requests from different IP in strict mode", async () => {
// Test
});
it("should warn but allow in non-strict mode", async () => {
// Test
});
it("should handle proxy headers correctly", async () => {
// Test X-Forwarded-For
});
});Considerations
Pros
- Prevents session hijacking
- Additional layer of security
- Configurable strictness
Cons
- Mobile users change IPs frequently
- VPNs/proxies cause issues
- Corporate networks with multiple egress IPs
Recommendation
Use non-strict mode by default, strict mode for high-security deployments.
Deployment Scenarios
High Security Environment
SESSION_IP_BINDING_ENABLED=true
SESSION_IP_BINDING_STRICT=trueBest for: Internal tools, desktop-only applications, controlled network environments.
Mobile/Flexible Environment
SESSION_IP_BINDING_ENABLED=true
SESSION_IP_BINDING_STRICT=falseBest for: Mobile apps, users on VPNs, corporate networks with load balancers.
Development
SESSION_IP_BINDING_ENABLED=falseBest for: Local development, testing.
Monitoring
Add metrics for IP binding violations:
export const sessionIpMismatch = new promClient.Counter({
name: "session_ip_mismatch_total",
help: "Total session IP mismatches detected",
labelNames: ["strict_mode", "action"],
});Set up alerts for suspicious activity:
rate(session_ip_mismatch_total{action="rejected"}[5m]) > 10Related Enhancements
- RBAC Implementation - Additional access controls
- Audit Logging - Track security events