Skip to content

Commit 571013a

Browse files
authored
Block wallet mode on HTTP transports to prevent CORS-based attacks (#298)
* Add CORS middleware and limit wallet mode to stdio * Add changeset * Fix tests * Increase test coverage * Address comments * Consolidate types
1 parent 202703c commit 571013a

11 files changed

Lines changed: 332 additions & 84 deletions

File tree

.changeset/clever-nights-enjoy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sei-js/mcp-server": patch
3+
---
4+
5+
Block wallet mode on HTTP transports to prevent CORS-based attacks

packages/mcp-server/src/server/args.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Examples:
9797
Streamable HTTP transport with custom path:
9898
$ SERVER_TRANSPORT=streamable-http SERVER_PORT=8080 SERVER_PATH=/api/mcp npx ${packageInfo.name}
9999
100-
With wallet enabled:
100+
With wallet enabled (STDIO transport only):
101101
$ WALLET_MODE=private-key PRIVATE_KEY=your_private_key_here npx ${packageInfo.name}
102102
103103
Environment Variables:
@@ -110,6 +110,10 @@ Environment Variables:
110110
MAINNET_RPC_URL Custom RPC URL for Sei mainnet (optional)
111111
TESTNET_RPC_URL Custom RPC URL for Sei testnet (optional)
112112
DEVNET_RPC_URL Custom RPC URL for Sei devnet (optional)
113+
114+
Security Note:
115+
Wallet mode is only supported with stdio transport. HTTP transports block
116+
wallet mode to prevent cross-origin attacks from malicious websites.
113117
`);
114118

115119
program.parse();

packages/mcp-server/src/server/transport/factory.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@ export const createTransport = (config: TransportConfig): McpTransport => {
99
return new StdioTransport();
1010

1111
case 'streamable-http':
12-
return new StreamableHttpTransport(config.port, config.host, config.path);
12+
return new StreamableHttpTransport(config.port, config.host, config.path, config.walletMode);
1313

1414
case 'http-sse':
15-
return new HttpSseTransport(config.port, config.host, config.path);
15+
return new HttpSseTransport(config.port, config.host, config.path, config.walletMode);
1616

1717
default:
1818
throw new Error(`Unsupported transport mode: ${config.mode}`);
1919
}
2020
};
21-
22-

packages/mcp-server/src/server/transport/http-sse.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,35 @@
11
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
3-
import cors from 'cors';
43
import express, { type Request, type Response } from 'express';
54
import type { Server } from 'node:http';
6-
import type { McpTransport } from './types.js';
5+
import type { McpTransport, WalletMode } from './types.js';
6+
import { createCorsMiddleware, validateSecurityConfig } from './security.js';
77

88
export class HttpSseTransport implements McpTransport {
99
readonly mode = 'http-sse' as const;
1010
private app: express.Application;
1111
private httpServer: Server | null = null;
1212
private connections = new Map<string, SSEServerTransport>();
1313
private mcpServer: McpServer | null = null;
14+
private walletMode: WalletMode;
1415

1516
constructor(
1617
private port: number,
1718
private host: string,
18-
private path: string
19+
private path: string,
20+
walletMode: WalletMode = 'disabled'
1921
) {
22+
this.walletMode = walletMode;
2023
this.app = express();
2124
this.setupMiddleware();
2225
this.setupRoutes();
2326
}
2427

2528
private setupMiddleware() {
2629
this.app.use(express.json());
27-
this.app.use(
28-
cors({
29-
origin: '*',
30-
methods: ['GET', 'POST', 'OPTIONS'],
31-
allowedHeaders: ['Content-Type', 'Authorization'],
32-
credentials: true,
33-
exposedHeaders: ['Content-Type', 'Access-Control-Allow-Origin']
34-
})
35-
);
36-
this.app.options('*', cors());
30+
31+
// Secure CORS - no cross-origin allowed by default
32+
this.app.use(createCorsMiddleware());
3733
}
3834

3935
private setupRoutes() {
@@ -82,6 +78,9 @@ export class HttpSseTransport implements McpTransport {
8278
}
8379

8480
async start(server: McpServer): Promise<void> {
81+
// Block wallet mode on HTTP transports
82+
validateSecurityConfig(this.mode, this.walletMode);
83+
8584
this.mcpServer = server;
8685
return new Promise((resolve, reject) => {
8786
this.httpServer = this.app.listen(this.port, this.host, () => {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Request, Response, NextFunction, RequestHandler } from 'express';
2+
import type { TransportMode, WalletMode } from './types.js';
3+
4+
/**
5+
* Creates CORS middleware with secure defaults.
6+
* By default, no CORS headers are set (same-origin only).
7+
*/
8+
export function createCorsMiddleware(): RequestHandler {
9+
return (req: Request, res: Response, next: NextFunction) => {
10+
// Handle preflight - reject cross-origin by default
11+
if (req.method === 'OPTIONS') {
12+
return res.sendStatus(204);
13+
}
14+
next();
15+
};
16+
}
17+
18+
/**
19+
* Validates that wallet mode is not used with HTTP transports
20+
* Exits the process if unsafe configuration detected
21+
*/
22+
export function validateSecurityConfig(
23+
transportMode: TransportMode,
24+
walletMode: WalletMode
25+
): void {
26+
const isHttpTransport = transportMode === 'streamable-http' || transportMode === 'http-sse';
27+
const isWalletEnabled = walletMode !== 'disabled';
28+
29+
if (isHttpTransport && isWalletEnabled) {
30+
console.error('');
31+
console.error('╔════════════════════════════════════════════════════════════════╗');
32+
console.error('║ SECURITY ERROR ║');
33+
console.error('╠════════════════════════════════════════════════════════════════╣');
34+
console.error('║ Wallet mode cannot be used with HTTP transports! ║');
35+
console.error('║ ║');
36+
console.error('║ HTTP transports expose the server to cross-origin requests, ║');
37+
console.error('║ allowing malicious websites to steal funds from your wallet. ║');
38+
console.error('║ ║');
39+
console.error('║ Use stdio transport instead (default, works with Claude): ║');
40+
console.error('║ $ WALLET_MODE=private-key PRIVATE_KEY=... npx @sei-js/mcp-server');
41+
console.error('╚════════════════════════════════════════════════════════════════╝');
42+
console.error('');
43+
process.exit(1);
44+
}
45+
}

packages/mcp-server/src/server/transport/streamable-http.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,42 @@ import express, { type Request, type Response } from 'express';
22
import type { Server } from 'node:http';
33
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
44
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5-
import type { McpTransport, TransportMode } from './types.js';
6-
import {getServer} from '../server.js';
5+
import type { McpTransport, TransportMode, WalletMode } from './types.js';
6+
import { createCorsMiddleware, validateSecurityConfig } from './security.js';
7+
import { getServer } from '../server.js';
78

89
export class StreamableHttpTransport implements McpTransport {
910
public readonly mode: TransportMode = 'streamable-http';
1011
private port: number;
1112
private host: string;
1213
private path: string;
14+
private walletMode: WalletMode;
1315
private app?: express.Express;
1416
private server?: Server;
1517

16-
constructor(port = 8080, host = 'localhost', path = '/mcp') {
18+
constructor(port = 8080, host = 'localhost', path = '/mcp', walletMode: WalletMode = 'disabled') {
1719
this.port = port;
1820
this.host = host;
1921
this.path = path;
22+
this.walletMode = walletMode;
2023
}
2124

2225
// Note: server parameter ignored for now as this is a stateless server
2326
// TODO: allow creating both stateless and stateful remote MCP servers
2427
async start(_server: McpServer): Promise<void> {
28+
// Block wallet mode on HTTP transports
29+
validateSecurityConfig(this.mode, this.walletMode);
30+
2531
this.app = express();
2632
this.app.use(express.json());
27-
this.app.use((req, res, next) => {
28-
res.header('Access-Control-Allow-Origin', '*');
29-
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
30-
res.header('Access-Control-Allow-Headers', 'Content-Type');
31-
if (req.method === 'OPTIONS') {
32-
return res.sendStatus(200);
33-
}
34-
next();
35-
});
33+
34+
// Secure CORS - no cross-origin allowed by default
35+
this.app.use(createCorsMiddleware());
3636

37+
// Health check endpoint
38+
this.app.get('/health', (_req: Request, res: Response) => {
39+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
40+
});
3741

3842
this.app.post(this.path, async (req: Request, res: Response) => {
3943
try {
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2+
import type { WalletMode } from '../../core/config.js';
23

34
export type TransportMode = 'stdio' | 'streamable-http' | 'http-sse';
5+
46
export interface McpTransport {
57
start(server: McpServer): Promise<void>;
68
stop(): Promise<void>;
@@ -9,8 +11,11 @@ export interface McpTransport {
911

1012
export interface TransportConfig {
1113
mode: TransportMode;
12-
walletMode: 'disabled' | 'private-key';
14+
walletMode: WalletMode;
1315
port: number; // Required for HTTP-based transports
1416
host: string; // Required for HTTP-based transports
1517
path: string; // Required for HTTP-based transports
1618
}
19+
20+
// Re-export WalletMode for convenience
21+
export type { WalletMode };

packages/mcp-server/src/tests/server/transport/factory.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('Transport Factory', () => {
7070

7171
const transport = createTransport(config);
7272

73-
expect(StreamableHttpTransport).toHaveBeenCalledWith(8080, '0.0.0.0', '/api/mcp');
73+
expect(StreamableHttpTransport).toHaveBeenCalledWith(8080, '0.0.0.0', '/api/mcp', 'private-key');
7474
expect(transport).toBe(mockStreamableInstance);
7575
});
7676

@@ -88,7 +88,7 @@ describe('Transport Factory', () => {
8888

8989
const transport = createTransport(config);
9090

91-
expect(HttpSseTransport).toHaveBeenCalledWith(9000, '127.0.0.1', '/sse');
91+
expect(HttpSseTransport).toHaveBeenCalledWith(9000, '127.0.0.1', '/sse', 'disabled');
9292
expect(transport).toBe(mockSseInstance);
9393
});
9494

@@ -123,7 +123,7 @@ describe('Transport Factory', () => {
123123

124124
const transport = createTransport(config);
125125

126-
expect(StreamableHttpTransport).toHaveBeenCalledWith(params.port, params.host, params.path);
126+
expect(StreamableHttpTransport).toHaveBeenCalledWith(params.port, params.host, params.path, 'disabled');
127127
expect(transport).toBe(mockInstance);
128128

129129
jest.clearAllMocks();
@@ -145,7 +145,7 @@ describe('Transport Factory', () => {
145145

146146
const transport1 = createTransport(config1);
147147

148-
expect(HttpSseTransport).toHaveBeenCalledWith(1, '::1', '/');
148+
expect(HttpSseTransport).toHaveBeenCalledWith(1, '::1', '/', 'private-key');
149149
expect(transport1).toBe(mockInstance1);
150150

151151
jest.clearAllMocks();
@@ -164,7 +164,7 @@ describe('Transport Factory', () => {
164164

165165
const transport2 = createTransport(config2);
166166

167-
expect(StreamableHttpTransport).toHaveBeenCalledWith(65535, '0.0.0.0', '/very/long/path/to/test/edge/cases');
167+
expect(StreamableHttpTransport).toHaveBeenCalledWith(65535, '0.0.0.0', '/very/long/path/to/test/edge/cases', 'disabled');
168168
expect(transport2).toBe(mockInstance2);
169169
});
170170
});

packages/mcp-server/src/tests/server/transport/http-sse.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ jest.mock('express', () => {
1616
return express;
1717
});
1818

19-
jest.mock('cors', () => jest.fn(() => 'cors-middleware'));
19+
jest.mock('../../../server/transport/security.js', () => ({
20+
createCorsMiddleware: jest.fn(() => 'cors-middleware'),
21+
validateSecurityConfig: jest.fn()
22+
}));
2023

2124
jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
2225
SSEServerTransport: jest.fn()
@@ -27,7 +30,8 @@ describe('HttpSseTransport', () => {
2730
let mockExpress: jest.MockedFunction<any>;
2831
let mockApp: any;
2932
let mockServer: any;
30-
let mockCors: jest.MockedFunction<any>;
33+
let mockCreateCorsMiddleware: jest.MockedFunction<any>;
34+
let mockValidateSecurityConfig: jest.MockedFunction<any>;
3135
let mockSSEServerTransport: jest.MockedFunction<any>;
3236
let mockTransport: any;
3337
let mockMcpServer: any;
@@ -38,11 +42,12 @@ describe('HttpSseTransport', () => {
3842

3943
// Import mocked modules
4044
const expressModule = await import('express');
41-
const corsModule = await import('cors');
45+
const securityModule = await import('../../../server/transport/security.js');
4246
const { SSEServerTransport } = await import('@modelcontextprotocol/sdk/server/sse.js');
4347

4448
mockExpress = expressModule.default as jest.MockedFunction<any>;
45-
mockCors = corsModule.default as jest.MockedFunction<any>;
49+
mockCreateCorsMiddleware = securityModule.createCorsMiddleware as jest.MockedFunction<any>;
50+
mockValidateSecurityConfig = securityModule.validateSecurityConfig as jest.MockedFunction<any>;
4651
mockSSEServerTransport = SSEServerTransport as jest.MockedFunction<any>;
4752

4853
// Setup mock objects
@@ -70,7 +75,7 @@ describe('HttpSseTransport', () => {
7075
// Configure mocks
7176
mockExpress.mockReturnValue(mockApp);
7277
mockExpress.json = jest.fn().mockReturnValue('json-middleware');
73-
mockCors.mockReturnValue('cors-middleware');
78+
mockCreateCorsMiddleware.mockReturnValue('cors-middleware');
7479
mockSSEServerTransport.mockImplementation(() => mockTransport);
7580

7681
// Import the class after mocks are set up
@@ -96,14 +101,8 @@ describe('HttpSseTransport', () => {
96101

97102
expect(mockExpress).toHaveBeenCalled();
98103
expect(mockApp.use).toHaveBeenCalledWith('json-middleware');
99-
expect(mockCors).toHaveBeenCalledWith({
100-
origin: '*',
101-
methods: ['GET', 'POST', 'OPTIONS'],
102-
allowedHeaders: ['Content-Type', 'Authorization'],
103-
credentials: true,
104-
exposedHeaders: ['Content-Type', 'Access-Control-Allow-Origin']
105-
});
106-
expect(mockApp.options).toHaveBeenCalledWith('*', 'cors-middleware');
104+
expect(mockCreateCorsMiddleware).toHaveBeenCalled();
105+
expect(mockApp.use).toHaveBeenCalledWith('cors-middleware');
107106
expect(mockApp.get).toHaveBeenCalledWith('/health', expect.any(Function));
108107
expect(mockApp.get).toHaveBeenCalledWith('/sse', expect.any(Function));
109108
expect(mockApp.post).toHaveBeenCalledWith('/sse/message', expect.any(Function));

0 commit comments

Comments
 (0)