@pmoses-s1/s1-secops-mcp 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Streamable HTTP transport (MCP 2024-11-05 / 2025-06-18 compatible subset).
3
+ *
4
+ * Single endpoint:
5
+ * POST /mcp accept JSON-RPC, return JSON-RPC reply (Content-Type: application/json)
6
+ * GET /healthz return 200 OK (for load balancers / systemd readiness checks)
7
+ * Any other path / method returns 404 or 405.
8
+ *
9
+ * The server is stateless: it does not maintain MCP sessions or push
10
+ * server-initiated notifications, so the spec-allowed SSE response form
11
+ * is not used. Clients that try to GET /mcp for a server-initiated stream
12
+ * receive 405 Method Not Allowed, which spec-compliant clients tolerate.
13
+ *
14
+ * Auth: if any tokens are loaded via lib/auth.js, every POST /mcp must
15
+ * carry "Authorization: Bearer <token>". Missing/invalid token -> 401.
16
+ *
17
+ * Audit: every authenticated request logs to stderr (captured by journald
18
+ * on systemd or by Docker on container runtimes):
19
+ * timestamp | client-name | method | params-summary | response-status
20
+ *
21
+ * Zero external dependencies (uses node:http).
22
+ */
23
+
24
+ import { createServer } from 'http';
25
+ import { authenticate, isAuthConfigured, warnIfNoAuth, authSourceForLogging } from './auth.js';
26
+ import { err as makeErr } from './server-core.js';
27
+
28
+ const MAX_BODY_BYTES = 4 * 1024 * 1024; // 4 MB, well above any normal MCP call
29
+
30
+ function log(...args) {
31
+ process.stderr.write('[s1-secops-mcp] ' + args.join(' ') + '\n');
32
+ }
33
+
34
+ function audit(line) {
35
+ process.stderr.write(`[audit] ${line}\n`);
36
+ }
37
+
38
+ function summarizeParams(params) {
39
+ if (!params || typeof params !== 'object') return '';
40
+ if (params.name) return `name=${params.name}`; // tools/call
41
+ if (params.uri) return `uri=${params.uri}`; // resources/read
42
+ return '';
43
+ }
44
+
45
+ /**
46
+ * DNS-rebinding guard for the no-auth HTTP mode.
47
+ *
48
+ * The Host header must match the address the server is bound to (or a loopback
49
+ * literal when bound to loopback). A DNS-rebinding attack arrives with the
50
+ * attacker's Host (e.g. "rebind.attacker.example:8765"), which will not match
51
+ * the loopback bind. When bound to a wildcard address we cannot know the
52
+ * intended FQDN, so we accept any Host and rely on the Origin check to block
53
+ * browsers. HTTP/1.1 mandates a Host header, so a missing one is rejected.
54
+ */
55
+ function isAllowedHost(hostHeader, bindHost, bindPort) {
56
+ if (!hostHeader) return false;
57
+ const wildcard = ['0.0.0.0', '::', ''].includes(String(bindHost));
58
+ // Parse "hostname" or "hostname:port" (including IPv6 "[::1]:port").
59
+ const m = /^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/.exec(hostHeader.trim());
60
+ if (!m) return false;
61
+ const name = m[1].toLowerCase().replace(/^\[|\]$/g, '');
62
+ const port = m[2];
63
+ if (port !== undefined && Number(port) !== Number(bindPort)) return false;
64
+ if (wildcard) return true;
65
+ const loopback = ['127.0.0.1', '::1', 'localhost'];
66
+ const bind = String(bindHost).toLowerCase();
67
+ const allowed = new Set([bind, ...(loopback.includes(bind) ? loopback : [])]);
68
+ return allowed.has(name);
69
+ }
70
+
71
+ function readBody(req) {
72
+ return new Promise((resolve, reject) => {
73
+ let size = 0;
74
+ const chunks = [];
75
+ req.on('data', (chunk) => {
76
+ size += chunk.length;
77
+ if (size > MAX_BODY_BYTES) {
78
+ // Stop reading but do NOT destroy here: the caller must be able to
79
+ // write the 413 response first. Destroying the request tears down the
80
+ // socket and the client would see a connection reset with no body.
81
+ req.pause();
82
+ req.removeAllListeners('data');
83
+ reject(new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`));
84
+ return;
85
+ }
86
+ chunks.push(chunk);
87
+ });
88
+ req.on('end', () => {
89
+ try {
90
+ const raw = Buffer.concat(chunks).toString('utf-8');
91
+ resolve(raw);
92
+ } catch (e) { reject(e); }
93
+ });
94
+ req.on('error', reject);
95
+ });
96
+ }
97
+
98
+ function sendJson(res, status, obj) {
99
+ const body = JSON.stringify(obj);
100
+ res.writeHead(status, {
101
+ 'Content-Type': 'application/json',
102
+ 'Content-Length': Buffer.byteLength(body),
103
+ 'Cache-Control': 'no-store',
104
+ });
105
+ res.end(body);
106
+ }
107
+
108
+ function sendText(res, status, text) {
109
+ res.writeHead(status, {
110
+ 'Content-Type': 'text/plain; charset=utf-8',
111
+ 'Content-Length': Buffer.byteLength(text),
112
+ 'Cache-Control': 'no-store',
113
+ });
114
+ res.end(text);
115
+ }
116
+
117
+ async function handleMcp(req, res, dispatch, clientIp, bindHost, bindPort) {
118
+ // Auth check (only if any tokens are configured)
119
+ let clientName = '-';
120
+ if (isAuthConfigured()) {
121
+ clientName = authenticate(req.headers['authorization']) || '';
122
+ if (!clientName) {
123
+ audit(`${new Date().toISOString()} | ${clientIp} | - | - | 401 unauthorized`);
124
+ sendJson(res, 401, makeErr(null, -32001, 'Unauthorized: missing or invalid bearer token'));
125
+ return;
126
+ }
127
+ } else {
128
+ // SECURITY (no-auth mode): a configured bearer token normally blocks
129
+ // browser-driven requests, because a web page cannot forge an Authorization
130
+ // header. The documented no-auth loopback mode has no such gate, so a
131
+ // malicious page, or a DNS-rebinding attack, on the operator's own
132
+ // workstation could otherwise drive this server via cross-origin fetch()
133
+ // and invoke every state-changing tool with the tenant API token. Per the
134
+ // MCP Streamable HTTP spec we reject any browser Origin and validate the
135
+ // Host header to defeat DNS rebinding. Non-browser MCP clients (stdio
136
+ // bridge, curl, Claude Desktop) omit Origin and send a matching Host.
137
+ const origin = req.headers['origin'];
138
+ if (origin) {
139
+ audit(`${new Date().toISOString()} | ${clientIp} | - | - | 403 cross-origin (${origin})`);
140
+ sendJson(res, 403, makeErr(null, -32001, 'Cross-origin requests are not allowed'));
141
+ return;
142
+ }
143
+ if (!isAllowedHost(req.headers['host'], bindHost, bindPort)) {
144
+ audit(`${new Date().toISOString()} | ${clientIp} | - | - | 403 bad-host (${req.headers['host'] || '-'})`);
145
+ sendJson(res, 403, makeErr(null, -32001, 'Invalid Host header'));
146
+ return;
147
+ }
148
+ clientName = `anon@${clientIp}`;
149
+ }
150
+
151
+ // Parse JSON body
152
+ let raw;
153
+ try {
154
+ raw = await readBody(req);
155
+ } catch (e) {
156
+ audit(`${new Date().toISOString()} | ${clientName} | - | - | 413 body-too-large`);
157
+ // Send the 413 first, then drop the connection once the response has been
158
+ // flushed. Destroying the request before responding loses the error body.
159
+ res.on('finish', () => req.destroy());
160
+ sendJson(res, 413, makeErr(null, -32600, e.message));
161
+ return;
162
+ }
163
+
164
+ if (!raw) {
165
+ sendJson(res, 400, makeErr(null, -32600, 'Empty body'));
166
+ return;
167
+ }
168
+
169
+ let msg;
170
+ try {
171
+ msg = JSON.parse(raw);
172
+ } catch (e) {
173
+ audit(`${new Date().toISOString()} | ${clientName} | - | - | 400 parse-error`);
174
+ sendJson(res, 400, makeErr(null, -32700, `Parse error: ${e.message}`));
175
+ return;
176
+ }
177
+
178
+ // JSON-RPC batch: out of spec for Streamable HTTP MCP; reject explicitly.
179
+ if (Array.isArray(msg)) {
180
+ sendJson(res, 400, makeErr(null, -32600, 'Batch requests are not supported'));
181
+ return;
182
+ }
183
+
184
+ // Validate JSON-RPC request shape before dispatch: reject scalars, null, and
185
+ // objects without jsonrpc:"2.0" + a string method as -32600 (Invalid Request)
186
+ // instead of silently treating them as notifications.
187
+ if (msg === null || typeof msg !== 'object' || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
188
+ const badId = (msg && typeof msg === 'object') ? (msg.id ?? null) : null;
189
+ audit(`${new Date().toISOString()} | ${clientName} | - | - | 400 invalid-request`);
190
+ sendJson(res, 400, makeErr(badId, -32600, 'Invalid Request: expected a JSON-RPC 2.0 object with a string "method"'));
191
+ return;
192
+ }
193
+
194
+ const isNotification = msg.id === undefined;
195
+ const ts = new Date().toISOString();
196
+
197
+ try {
198
+ const response = await dispatch(msg.method, msg.params, msg.id);
199
+
200
+ if (isNotification) {
201
+ // Per JSON-RPC, notifications get no reply; per MCP Streamable HTTP, return 202.
202
+ audit(`${ts} | ${clientName} | ${msg.method} | ${summarizeParams(msg.params)} | 202 notification`);
203
+ res.writeHead(202);
204
+ res.end();
205
+ return;
206
+ }
207
+
208
+ if (response === null) {
209
+ sendJson(res, 200, makeErr(msg.id ?? null, -32603, 'Internal error: empty response'));
210
+ return;
211
+ }
212
+
213
+ const status = 200; // JSON-RPC errors still return HTTP 200 with an error envelope
214
+ audit(`${ts} | ${clientName} | ${msg.method} | ${summarizeParams(msg.params)} | ${response.error ? `200 jsonrpc-error (${response.error.code})` : '200 ok'}`);
215
+ sendJson(res, status, response);
216
+
217
+ } catch (e) {
218
+ log('Dispatch error:', e.message, e.stack);
219
+ audit(`${ts} | ${clientName} | ${msg.method || '-'} | ${summarizeParams(msg.params)} | 500 internal-error`);
220
+ if (!isNotification) {
221
+ sendJson(res, 500, makeErr(msg.id ?? null, -32603, `Internal error: ${e.message}`));
222
+ } else {
223
+ res.writeHead(500);
224
+ res.end();
225
+ }
226
+ }
227
+ }
228
+
229
+ export async function startHttp(dispatch, { port, host, path }) {
230
+ warnIfNoAuth(host);
231
+
232
+ const server = createServer(async (req, res) => {
233
+ const clientIp = req.socket.remoteAddress || '-';
234
+
235
+ // Health check
236
+ if (req.method === 'GET' && (req.url === '/healthz' || req.url === '/health')) {
237
+ sendText(res, 200, 'ok\n');
238
+ return;
239
+ }
240
+
241
+ // MCP endpoint
242
+ if (req.url === path) {
243
+ if (req.method === 'POST') {
244
+ await handleMcp(req, res, dispatch, clientIp, host, port);
245
+ return;
246
+ }
247
+ if (req.method === 'GET' || req.method === 'DELETE') {
248
+ // Spec-allowed but not implemented (no server-push, no sessions).
249
+ res.writeHead(405, { 'Allow': 'POST', 'Content-Type': 'text/plain' });
250
+ res.end('Method not allowed; this server accepts POST only.\n');
251
+ return;
252
+ }
253
+ res.writeHead(405, { 'Allow': 'POST', 'Content-Type': 'text/plain' });
254
+ res.end('Method not allowed\n');
255
+ return;
256
+ }
257
+
258
+ sendText(res, 404, 'Not found. The MCP endpoint is ' + path + '.\n');
259
+ });
260
+
261
+ server.on('error', (e) => {
262
+ log(`HTTP server error: ${e.message}`);
263
+ if (e.code === 'EADDRINUSE') {
264
+ log(`Port ${port} is already in use. Pick a different --port.`);
265
+ }
266
+ // Any server error before a successful listen (EACCES, EADDRNOTAVAIL,
267
+ // invalid host, ...) is fatal: exit nonzero so systemd/Docker restart
268
+ // policies see the failure instead of a silently dead server.
269
+ process.exit(1);
270
+ });
271
+
272
+ await new Promise((resolve) => server.listen(port, host, resolve));
273
+ const authMode = isAuthConfigured() ? authSourceForLogging() : 'NONE (warn)';
274
+ log(`Transport: streamableHttp listening on http://${host}:${port}${path} (auth: ${authMode})`);
275
+
276
+ // Graceful shutdown
277
+ process.on('SIGINT', () => {
278
+ log('SIGINT received, draining HTTP server...');
279
+ server.close(() => process.exit(0));
280
+ setTimeout(() => process.exit(0), 5000).unref();
281
+ });
282
+ process.on('SIGTERM', () => {
283
+ log('SIGTERM received, draining HTTP server...');
284
+ server.close(() => process.exit(0));
285
+ setTimeout(() => process.exit(0), 5000).unref();
286
+ });
287
+
288
+ return server;
289
+ }