@casys/mcp-server 0.2.1

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,140 @@
1
+ /**
2
+ * HMAC Channel Authentication — Inline Script for PostMessage (MCP Apps)
3
+ *
4
+ * Generates an inline `<script>` that signs outgoing JSON-RPC messages
5
+ * from an iframe to its parent via PostMessage (HMAC-SHA256 + anti-replay).
6
+ *
7
+ * The HOST side is responsible for verifying incoming messages using
8
+ * `MessageSigner.verify()`. This script only handles the IFRAME side
9
+ * (signing outgoing).
10
+ *
11
+ * Part of @casys/mcp-server security module. See also:
12
+ * - message-signer.ts (MessageSigner class, HMAC sign/verify)
13
+ *
14
+ * @module server/security/channel-hmac
15
+ */
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Inline HMAC Script
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Generate the inline `<script>` tag for iframe HMAC signing.
23
+ *
24
+ * The script:
25
+ * 1. Embeds the channel secret (invisible to other iframes due to cross-origin)
26
+ * 2. Monkey-patches `window.parent.postMessage` to sign outgoing JSON-RPC
27
+ *
28
+ * Verification of incoming messages is the HOST's responsibility
29
+ * (via `MessageSigner.verify()`), not the iframe's.
30
+ *
31
+ * @param secret - 64-char hex secret from MessageSigner.generateSecret()
32
+ * @returns HTML `<script>` string ready for injection into `<head>`
33
+ */
34
+ export function generateHmacScript(secret: string): string {
35
+ return `
36
+ <script data-mcp-channel-auth>
37
+ (function() {
38
+ 'use strict';
39
+
40
+ var SECRET_HEX = '${secret}';
41
+ var sendSeq = 0;
42
+ var cryptoKeyPromise = null;
43
+
44
+ function hexToBytes(hex) {
45
+ var bytes = new Uint8Array(hex.length / 2);
46
+ for (var i = 0; i < hex.length; i += 2) {
47
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
48
+ }
49
+ return bytes;
50
+ }
51
+
52
+ function bytesToHex(bytes) {
53
+ var hex = '';
54
+ for (var i = 0; i < bytes.length; i++) {
55
+ hex += bytes[i].toString(16).padStart(2, '0');
56
+ }
57
+ return hex;
58
+ }
59
+
60
+ function initKey() {
61
+ if (cryptoKeyPromise) return cryptoKeyPromise;
62
+ cryptoKeyPromise = crypto.subtle.importKey(
63
+ 'raw', hexToBytes(SECRET_HEX),
64
+ { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
65
+ );
66
+ return cryptoKeyPromise;
67
+ }
68
+
69
+ function buildPayload(msg, seq) {
70
+ var id = msg.id != null ? msg.id : '';
71
+ var method = msg.method || '';
72
+ var body;
73
+ if (msg.params !== undefined) body = JSON.stringify(msg.params);
74
+ else if (msg.result !== undefined) body = JSON.stringify(msg.result);
75
+ else if (msg.error !== undefined) body = JSON.stringify(msg.error);
76
+ else body = '{}';
77
+ return seq + ':' + id + ':' + method + ':' + body;
78
+ }
79
+
80
+ function signMessage(msg) {
81
+ var seq = sendSeq++;
82
+ var payload = buildPayload(msg, seq);
83
+ return initKey().then(function(key) {
84
+ return crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
85
+ }).then(function(sig) {
86
+ var signed = {};
87
+ for (var k in msg) {
88
+ if (Object.prototype.hasOwnProperty.call(msg, k)) signed[k] = msg[k];
89
+ }
90
+ signed._seq = seq;
91
+ signed._hmac = bytesToHex(new Uint8Array(sig));
92
+ return signed;
93
+ });
94
+ }
95
+
96
+ // Monkey-patch outgoing postMessage (iframe -> parent)
97
+ var realPostMessage = window.parent.postMessage.bind(window.parent);
98
+ window.parent.postMessage = function(message, targetOrigin, transfer) {
99
+ if (message && typeof message === 'object' && message.jsonrpc === '2.0') {
100
+ signMessage(message).then(function(signed) {
101
+ realPostMessage(signed, targetOrigin, transfer);
102
+ }).catch(function(err) {
103
+ console.error('[mcp-channel-auth] Sign error:', err);
104
+ });
105
+ } else {
106
+ realPostMessage(message, targetOrigin, transfer);
107
+ }
108
+ };
109
+
110
+ initKey();
111
+ })();
112
+ </script>
113
+ `;
114
+ }
115
+
116
+ /**
117
+ * Inject channel authentication script into HTML content.
118
+ *
119
+ * Inserts the HMAC signing script before `</head>` (preferred) or at the start.
120
+ * The injected script signs all outgoing JSON-RPC postMessages from the iframe.
121
+ * The HOST must verify these signatures using `MessageSigner.verify()`.
122
+ *
123
+ * @param html - HTML content to inject into
124
+ * @param secret - 64-char hex secret from MessageSigner.generateSecret()
125
+ * @returns Modified HTML with HMAC signing script injected
126
+ * @throws {Error} If secret is not a valid 64-char hex string
127
+ */
128
+ export function injectChannelAuth(html: string, secret: string): string {
129
+ if (!/^[0-9a-f]{64}$/.test(secret)) {
130
+ throw new Error(
131
+ "[injectChannelAuth] Invalid secret: expected 64-char lowercase hex string. " +
132
+ "Use MessageSigner.generateSecret() to create one.",
133
+ );
134
+ }
135
+ const script = generateHmacScript(secret);
136
+ if (html.includes("</head>")) {
137
+ return html.replace("</head>", script + "</head>");
138
+ }
139
+ return script + html;
140
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Content Security Policy helpers for MCP Apps HTML resources.
3
+ *
4
+ * Provides CSP header generation and HTML meta tag injection to protect
5
+ * against XSS and unauthorized resource loading in MCP App iframes.
6
+ *
7
+ * @module lib/server/security/csp
8
+ */
9
+
10
+ /** Options for generating a CSP header value. */
11
+ export interface CspOptions {
12
+ /** Additional allowed script sources (e.g. CDN URLs). */
13
+ readonly scriptSources?: readonly string[];
14
+ /** Additional allowed connect sources (e.g. WebSocket endpoints). */
15
+ readonly connectSources?: readonly string[];
16
+ /** Additional allowed frame ancestors. */
17
+ readonly frameAncestors?: readonly string[];
18
+ /**
19
+ * Allow `'unsafe-inline'` for scripts and styles (default: true).
20
+ * MCP Apps typically need inline scripts/styles for single-file HTML UIs.
21
+ */
22
+ readonly allowInline?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Build a Content-Security-Policy header value.
27
+ *
28
+ * Uses `default-src 'none'` as the baseline (deny-all), then explicitly allows
29
+ * only what MCP App UIs need. Inline scripts/styles are allowed by default
30
+ * since MCP Apps are typically single-file HTML with inline code.
31
+ *
32
+ * @param options - CSP configuration
33
+ * @returns CSP header value string
34
+ */
35
+ export function buildCspHeader(options: CspOptions = {}): string {
36
+ const allowInline = options.allowInline !== false;
37
+ const inlineDirective = allowInline ? " 'unsafe-inline'" : "";
38
+
39
+ const scriptSrc = [
40
+ `'self'${inlineDirective}`,
41
+ ...(options.scriptSources ?? []),
42
+ ].join(" ");
43
+ const connectSrc = ["'self'", ...(options.connectSources ?? [])].join(" ");
44
+ const frameAncestors = ["'self'", ...(options.frameAncestors ?? [])].join(
45
+ " ",
46
+ );
47
+
48
+ return [
49
+ `default-src 'none'`,
50
+ `script-src ${scriptSrc}`,
51
+ `style-src 'self'${inlineDirective}`,
52
+ `img-src 'self' data:`,
53
+ `font-src 'self'`,
54
+ `connect-src ${connectSrc}`,
55
+ `frame-ancestors ${frameAncestors}`,
56
+ `base-uri 'self'`,
57
+ ].join("; ");
58
+ }
59
+
60
+ /**
61
+ * Inject a CSP meta tag into HTML content.
62
+ *
63
+ * Inserts `<meta http-equiv="Content-Security-Policy" content="...">` right
64
+ * after the opening `<head>` tag. If no `<head>` tag exists, prepends to content.
65
+ *
66
+ * This provides CSP enforcement even when HTTP headers are unavailable
67
+ * (e.g. STDIO transport where there are no HTTP response headers).
68
+ *
69
+ * @param html - Original HTML content
70
+ * @param cspValue - CSP header value (from buildCspHeader)
71
+ * @returns HTML with injected CSP meta tag
72
+ */
73
+ export function injectCspMetaTag(html: string, cspValue: string): string {
74
+ const escaped = cspValue.replace(/"/g, "&quot;");
75
+ const metaTag =
76
+ `<meta http-equiv="Content-Security-Policy" content="${escaped}">`;
77
+
78
+ // Inject right after <head> (case-insensitive, handles attributes)
79
+ const headMatch = html.match(/<head[^>]*>/i);
80
+ if (headMatch && headMatch.index !== undefined) {
81
+ const insertPos = headMatch.index + headMatch[0].length;
82
+ return html.slice(0, insertPos) + metaTag + html.slice(insertPos);
83
+ }
84
+
85
+ // Fallback: prepend to content
86
+ return metaTag + html;
87
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * HMAC-SHA256 Message Signer for PostMessage Channels
3
+ *
4
+ * Signs and verifies JSON-RPC messages exchanged via PostMessage.
5
+ * Each message gains `_hmac` (signature) and `_seq` (monotonic counter)
6
+ * fields for authentication and anti-replay protection.
7
+ *
8
+ * HMAC payload: `"${_seq}:${id}:${method}:${JSON.stringify(params|result|error)}"`
9
+ *
10
+ * Uses the Web Crypto API exclusively (Deno, Node.js 18+, browsers).
11
+ *
12
+ * @module server/security/message-signer
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Hex utilities
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Convert a Uint8Array to a lowercase hex string. */
20
+ export function bytesToHex(bytes: Uint8Array): string {
21
+ const out: string[] = new Array(bytes.length);
22
+ for (let i = 0; i < bytes.length; i++) {
23
+ out[i] = bytes[i].toString(16).padStart(2, "0");
24
+ }
25
+ return out.join("");
26
+ }
27
+
28
+ /** Convert a hex string to a Uint8Array. Returns null if invalid hex. */
29
+ export function hexToBytes(hex: string): Uint8Array | null {
30
+ if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null;
31
+ const bytes = new Uint8Array(hex.length / 2);
32
+ for (let i = 0; i < hex.length; i += 2) {
33
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
34
+ }
35
+ return bytes;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Types
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** A JSON-RPC message with optional HMAC signature fields. */
43
+ export interface SignedMessage {
44
+ jsonrpc: "2.0";
45
+ id?: string | number;
46
+ method?: string;
47
+ params?: unknown;
48
+ result?: unknown;
49
+ error?: unknown;
50
+ _hmac?: string;
51
+ _seq?: number;
52
+ [key: string]: unknown;
53
+ }
54
+
55
+ /** Result of verifying a signed message. */
56
+ export interface VerifyResult {
57
+ /** Whether the signature is valid. */
58
+ valid: boolean;
59
+ /** The message with `_hmac` and `_seq` stripped. */
60
+ message: SignedMessage;
61
+ /** Error description when valid is false. */
62
+ error?: string;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // HMAC Payload
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Build the canonical HMAC payload string.
71
+ *
72
+ * Format: `"${seq}:${id}:${method}:${body}"`
73
+ */
74
+ export function buildHmacPayload(message: SignedMessage, seq: number): string {
75
+ const id = message.id ?? "";
76
+ const method = message.method ?? "";
77
+ let body: string;
78
+ if (message.params !== undefined) body = JSON.stringify(message.params);
79
+ else if (message.result !== undefined) body = JSON.stringify(message.result);
80
+ else if (message.error !== undefined) body = JSON.stringify(message.error);
81
+ else body = "{}";
82
+ return `${seq}:${id}:${method}:${body}`;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // MessageSigner
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * HMAC-SHA256 message signer/verifier for a single PostMessage channel.
91
+ *
92
+ * Maintains separate sequence counters for send and receive directions.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * const signer = new MessageSigner(secretHex);
97
+ * await signer.init();
98
+ *
99
+ * const signed = await signer.sign({ jsonrpc: '2.0', method: 'tools/call', params: {} });
100
+ * const result = await signer.verify(signed);
101
+ * ```
102
+ */
103
+ export class MessageSigner {
104
+ private cryptoKey: CryptoKey | null = null;
105
+ private sendSeq = 0;
106
+ private lastRecvSeq = -1;
107
+ private readonly secretHex: string;
108
+
109
+ /**
110
+ * Generate a 32-byte (256-bit) random hex secret for channel authentication.
111
+ * Uses `crypto.getRandomValues()` for cryptographic randomness.
112
+ *
113
+ * @returns 64-character lowercase hex string (32 bytes)
114
+ */
115
+ static generateSecret(): string {
116
+ const bytes = new Uint8Array(32);
117
+ crypto.getRandomValues(bytes);
118
+ return bytesToHex(bytes);
119
+ }
120
+
121
+ constructor(secretHex: string) {
122
+ this.secretHex = secretHex;
123
+ }
124
+
125
+ /**
126
+ * Initialize the CryptoKey. Must be called before sign()/verify().
127
+ * Idempotent.
128
+ */
129
+ async init(): Promise<void> {
130
+ if (this.cryptoKey) return;
131
+ const keyBytes = hexToBytes(this.secretHex);
132
+ if (!keyBytes) {
133
+ throw new Error(
134
+ "[MessageSigner] Invalid secret: must be a valid hex string. " +
135
+ "Use MessageSigner.generateSecret() to create one.",
136
+ );
137
+ }
138
+ this.cryptoKey = await crypto.subtle.importKey(
139
+ "raw",
140
+ keyBytes.buffer as ArrayBuffer,
141
+ { name: "HMAC", hash: "SHA-256" },
142
+ false,
143
+ ["sign", "verify"],
144
+ );
145
+ }
146
+
147
+ /** Sign a JSON-RPC message by adding `_hmac` and `_seq` fields. */
148
+ async sign(message: SignedMessage): Promise<SignedMessage> {
149
+ if (!this.cryptoKey) {
150
+ throw new Error(
151
+ "[MessageSigner] Not initialized. Call init() before sign().",
152
+ );
153
+ }
154
+ const seq = this.sendSeq++;
155
+ const payload = buildHmacPayload(message, seq);
156
+ const sig = await crypto.subtle.sign(
157
+ "HMAC",
158
+ this.cryptoKey,
159
+ new TextEncoder().encode(payload),
160
+ );
161
+ return { ...message, _seq: seq, _hmac: bytesToHex(new Uint8Array(sig)) };
162
+ }
163
+
164
+ /**
165
+ * Verify a signed message and strip `_hmac`/`_seq` fields.
166
+ *
167
+ * Rejects if: missing fields, replay (seq <= lastSeen), HMAC mismatch.
168
+ */
169
+ async verify(message: SignedMessage): Promise<VerifyResult> {
170
+ if (!this.cryptoKey) {
171
+ throw new Error(
172
+ "[MessageSigner] Not initialized. Call init() before verify().",
173
+ );
174
+ }
175
+ const { _hmac, _seq, ...clean } = message;
176
+
177
+ if (_hmac === undefined || _seq === undefined) {
178
+ return {
179
+ valid: false,
180
+ message: clean as SignedMessage,
181
+ error: "Missing _hmac or _seq field",
182
+ };
183
+ }
184
+ if (typeof _seq !== "number" || _seq <= this.lastRecvSeq) {
185
+ return {
186
+ valid: false,
187
+ message: clean as SignedMessage,
188
+ error:
189
+ `Replay detected: _seq=${_seq} <= lastRecvSeq=${this.lastRecvSeq}`,
190
+ };
191
+ }
192
+ const hmacBytes = hexToBytes(_hmac);
193
+ if (!hmacBytes) {
194
+ return {
195
+ valid: false,
196
+ message: clean as SignedMessage,
197
+ error: "Invalid _hmac: not valid hex",
198
+ };
199
+ }
200
+ const payload = buildHmacPayload(clean as SignedMessage, _seq);
201
+ const isValid = await crypto.subtle.verify(
202
+ "HMAC",
203
+ this.cryptoKey,
204
+ hmacBytes.buffer as ArrayBuffer,
205
+ new TextEncoder().encode(payload),
206
+ );
207
+ if (!isValid) {
208
+ return {
209
+ valid: false,
210
+ message: clean as SignedMessage,
211
+ error: "HMAC signature mismatch",
212
+ };
213
+ }
214
+ this.lastRecvSeq = _seq;
215
+ return { valid: true, message: clean as SignedMessage };
216
+ }
217
+
218
+ /** Reset sequence counters (for testing). */
219
+ reset(): void {
220
+ this.sendSeq = 0;
221
+ this.lastRecvSeq = -1;
222
+ }
223
+ }