mcp-local-tunnel 1.2.0 → 1.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.
- package/README.md +1 -0
- package/dist/pages.d.ts +1 -0
- package/dist/pages.js +15 -1
- package/dist/redirect-uri.d.ts +8 -0
- package/dist/redirect-uri.js +39 -0
- package/dist/relay-server.js +12 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,6 +110,7 @@ Only `mode` and `auth.issuer` are required. Everything else has sensible default
|
|
|
110
110
|
| `host` | No | Host to bind to. Defaults to `"::"`. |
|
|
111
111
|
| `issuerUrl` | No | Public URL of this server. Required when behind a reverse proxy. |
|
|
112
112
|
| `secret` | No | Signing key for tokens. Random if not set. Set a fixed value to survive restarts. |
|
|
113
|
+
| `allowedRedirectUris` | No | Hosted client redirect URIs that get a code without confirmation (loopback and native app URIs always do). Others see a page naming where their access is going first. Defaults to well-known hosted client callbacks (Claude, ChatGPT, Cursor; see `src/redirect-uri.ts`); setting this replaces the defaults. A trailing `*` matches any suffix. |
|
|
113
114
|
|
|
114
115
|
A full relay example:
|
|
115
116
|
|
package/dist/pages.d.ts
CHANGED
package/dist/pages.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.renderErrorPage = exports.renderSuccessPage = void 0;
|
|
3
|
+
exports.renderErrorPage = exports.renderRedirectConfirmPage = exports.renderSuccessPage = void 0;
|
|
4
4
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS module, can't use import.meta
|
|
5
5
|
const pkg = require('../package.json');
|
|
6
6
|
const { version } = pkg;
|
|
@@ -48,6 +48,20 @@ ${footerHtml}
|
|
|
48
48
|
</body>
|
|
49
49
|
</html>`;
|
|
50
50
|
exports.renderSuccessPage = renderSuccessPage;
|
|
51
|
+
const renderRedirectConfirmPage = (clientRedirectUrl, untrustedHost) => `${pageHead('Confirm access', `h1 { margin-bottom: 8px; }
|
|
52
|
+
.msg { font-size: 13px; color: var(--subtle); margin-bottom: 28px; }
|
|
53
|
+
.msg strong { color: var(--fg); }
|
|
54
|
+
.btn { display: inline-block; font: inherit; font-size: 12px; font-weight: 600; padding: 8px 20px; border-radius: 4px; border: none; cursor: pointer; text-decoration: none; background: var(--btn-bg); color: var(--btn-fg); }
|
|
55
|
+
.btn:hover { background: var(--btn-hover); }`)}
|
|
56
|
+
<body>
|
|
57
|
+
<h1>MCP Local Tunnel</h1>
|
|
58
|
+
<p class="msg">This will give <strong>${escapeHtml(untrustedHost)}</strong> access to your local MCP servers.</p>
|
|
59
|
+
<p class="msg">Only continue if you started this sign-in yourself and you recognise this address. If someone sent you a link, close this page.</p>
|
|
60
|
+
<a class="btn" href="${escapeHtml(clientRedirectUrl)}">continue to ${escapeHtml(untrustedHost)}</a>
|
|
61
|
+
${footerHtml}
|
|
62
|
+
</body>
|
|
63
|
+
</html>`;
|
|
64
|
+
exports.renderRedirectConfirmPage = renderRedirectConfirmPage;
|
|
51
65
|
const renderErrorPage = (message) => `${pageHead('Authentication failed', `${CENTER_CSS}
|
|
52
66
|
.msg-err { color: var(--error); }`)}
|
|
53
67
|
<body>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* We accept any redirect_uri without registration, and an attacker can start a flow with their
|
|
3
|
+
* own redirect_uri and PKCE pair. So only hand codes to redirect URIs a remote attacker can't
|
|
4
|
+
* receive; ask the user to confirm anything else.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_ALLOWED_REDIRECT_URIS: string[];
|
|
7
|
+
export type RedirectUriVerdict = 'allowed' | 'needs_consent' | 'invalid';
|
|
8
|
+
export declare function checkRedirectUri(redirectUri: string, allowedRedirectUris?: string[]): RedirectUriVerdict;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* We accept any redirect_uri without registration, and an attacker can start a flow with their
|
|
4
|
+
* own redirect_uri and PKCE pair. So only hand codes to redirect URIs a remote attacker can't
|
|
5
|
+
* receive; ask the user to confirm anything else.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.DEFAULT_ALLOWED_REDIRECT_URIS = void 0;
|
|
9
|
+
exports.checkRedirectUri = checkRedirectUri;
|
|
10
|
+
// A trailing * matches any suffix
|
|
11
|
+
exports.DEFAULT_ALLOWED_REDIRECT_URIS = [
|
|
12
|
+
'https://claude.ai/api/mcp/auth_callback',
|
|
13
|
+
'https://claude.com/api/mcp/auth_callback',
|
|
14
|
+
'https://chatgpt.com/connector_platform_oauth_redirect',
|
|
15
|
+
'https://chatgpt.com/connector/oauth/*',
|
|
16
|
+
'https://www.cursor.com/agents/mcp/oauth/callback',
|
|
17
|
+
];
|
|
18
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
19
|
+
// eslint-disable-next-line no-script-url
|
|
20
|
+
const BLOCKED_SCHEMES = new Set(['javascript:', 'data:', 'file:', 'blob:', 'about:', 'vbscript:', 'filesystem:']);
|
|
21
|
+
function checkRedirectUri(redirectUri, allowedRedirectUris = exports.DEFAULT_ALLOWED_REDIRECT_URIS) {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(redirectUri);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return 'invalid';
|
|
28
|
+
}
|
|
29
|
+
if (BLOCKED_SCHEMES.has(url.protocol) || url.hash) {
|
|
30
|
+
return 'invalid';
|
|
31
|
+
}
|
|
32
|
+
// Native app schemes and loopback only reach the user's own machine
|
|
33
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || LOOPBACK_HOSTNAMES.has(url.hostname)) {
|
|
34
|
+
return 'allowed';
|
|
35
|
+
}
|
|
36
|
+
const target = `${url.origin}${url.pathname}`;
|
|
37
|
+
const isAllowed = allowedRedirectUris.some((allowed) => (allowed.endsWith('*') ? target.startsWith(allowed.slice(0, -1)) : target === allowed));
|
|
38
|
+
return isAllowed ? 'allowed' : 'needs_consent';
|
|
39
|
+
}
|
package/dist/relay-server.js
CHANGED
|
@@ -11,6 +11,7 @@ const express_1 = __importDefault(require("express"));
|
|
|
11
11
|
const ws_1 = require("ws");
|
|
12
12
|
const relay_mcp_server_js_1 = require("./relay-mcp-server.js");
|
|
13
13
|
const pages_js_1 = require("./pages.js");
|
|
14
|
+
const redirect_uri_js_1 = require("./redirect-uri.js");
|
|
14
15
|
const getString = (value) => typeof value === 'string' ? value : undefined;
|
|
15
16
|
const createRelayApp = (config, provider, oidcClient, connectionManager) => {
|
|
16
17
|
const app = (0, express_1.default)();
|
|
@@ -34,6 +35,10 @@ const createRelayApp = (config, provider, oidcClient, connectionManager) => {
|
|
|
34
35
|
res.status(400).json({ error: 'invalid_request', error_description: 'code_challenge_method must be S256' });
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
38
|
+
if ((0, redirect_uri_js_1.checkRedirectUri)(redirectUri, config.allowedRedirectUris) === 'invalid') {
|
|
39
|
+
res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
37
42
|
const client = { client_id: clientId, redirect_uris: [redirectUri] };
|
|
38
43
|
const authParams = {
|
|
39
44
|
scopes: scope ? scope.split(' ') : [],
|
|
@@ -79,6 +84,13 @@ const createRelayApp = (config, provider, oidcClient, connectionManager) => {
|
|
|
79
84
|
const callbackUrl = `${getBaseUrl()}/callback`;
|
|
80
85
|
const { userId } = await oidcClient.exchangeCode(code, callbackUrl, pending.upstreamCodeVerifier);
|
|
81
86
|
const { redirectUrl } = provider.completeAuthorization(pending, userId);
|
|
87
|
+
// The redirect_uri is attacker-controllable (see redirect-uri.ts), so name it before handing over the code
|
|
88
|
+
if ((0, redirect_uri_js_1.checkRedirectUri)(pending.redirectUri, config.allowedRedirectUris) === 'needs_consent') {
|
|
89
|
+
res.set('X-Frame-Options', 'DENY');
|
|
90
|
+
res.set('Content-Security-Policy', 'frame-ancestors \'none\'');
|
|
91
|
+
res.type('html').send((0, pages_js_1.renderRedirectConfirmPage)(redirectUrl, new URL(pending.redirectUri).host));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
82
94
|
res.redirect(redirectUrl);
|
|
83
95
|
}
|
|
84
96
|
catch (err) {
|
package/dist/types.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export declare const RelayConfigSchema: z.ZodObject<{
|
|
|
24
24
|
host: z.ZodOptional<z.ZodString>;
|
|
25
25
|
issuerUrl: z.ZodOptional<z.ZodURL>;
|
|
26
26
|
secret: z.ZodOptional<z.ZodString>;
|
|
27
|
+
allowedRedirectUris: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
27
28
|
}, z.core.$strip>;
|
|
28
29
|
export declare const AgentConfigSchema: z.ZodObject<{
|
|
29
30
|
mode: z.ZodLiteral<"agent">;
|
|
@@ -48,6 +49,7 @@ export declare const ConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
48
49
|
host: z.ZodOptional<z.ZodString>;
|
|
49
50
|
issuerUrl: z.ZodOptional<z.ZodURL>;
|
|
50
51
|
secret: z.ZodOptional<z.ZodString>;
|
|
52
|
+
allowedRedirectUris: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
51
53
|
}, z.core.$strip>, z.ZodObject<{
|
|
52
54
|
mode: z.ZodLiteral<"agent">;
|
|
53
55
|
relay: z.ZodString;
|
package/dist/types.js
CHANGED
|
@@ -21,6 +21,11 @@ exports.RelayConfigSchema = zod_1.z.object({
|
|
|
21
21
|
host: zod_1.z.string().optional(),
|
|
22
22
|
issuerUrl: zod_1.z.url().optional(),
|
|
23
23
|
secret: zod_1.z.string().optional(),
|
|
24
|
+
/**
|
|
25
|
+
* Hosted client redirect URIs trusted without a confirmation step (loopback and native app URIs always are).
|
|
26
|
+
* Defaults to well-known hosted client callbacks (Claude, ChatGPT, Cursor; see `src/redirect-uri.ts`); setting this replaces the defaults. A trailing `*` matches any suffix.
|
|
27
|
+
*/
|
|
28
|
+
allowedRedirectUris: zod_1.z.array(zod_1.z.string()).optional(),
|
|
24
29
|
});
|
|
25
30
|
exports.AgentConfigSchema = zod_1.z.object({
|
|
26
31
|
mode: zod_1.z.literal('agent'),
|