actual-mcp-server 0.25.0 → 0.25.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 -1
- package/dist/src/lib/authPosture.js +35 -16
- package/dist/src/server/httpServer.js +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,35 +87,54 @@ export function findWeakTokenVars(env, { oidcEnabled }) {
|
|
|
87
87
|
}
|
|
88
88
|
/** #612: the hostnames every open-mode server accepts, in the SDK's bracketed form. */
|
|
89
89
|
const LOOPBACK_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
|
|
90
|
+
/**
|
|
91
|
+
* The hostname the SDK compares for `host` (a bind address or advertised name):
|
|
92
|
+
* lowercased, IPv6 bracketed and canonical. `undefined` for an empty or wildcard
|
|
93
|
+
* value, which names no single host.
|
|
94
|
+
*/
|
|
95
|
+
function canonicalHostname(host) {
|
|
96
|
+
const h = host?.trim().toLowerCase();
|
|
97
|
+
if (!h || h === '0.0.0.0' || h === '::' || h === '[::]')
|
|
98
|
+
return undefined;
|
|
99
|
+
const bracketed = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
|
|
100
|
+
// The SDK compares the URL-parsed Host, which is the canonical form
|
|
101
|
+
// ([2001:db8:0:0::5] arrives as [2001:db8::5]), so store the same form.
|
|
102
|
+
try {
|
|
103
|
+
return new URL(`http://${bracketed}`).hostname;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return bracketed;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
90
109
|
/**
|
|
91
110
|
* #612: the Host (and, in open mode, Origin) hostnames the HTTP server accepts, or
|
|
92
111
|
* `null` to skip the check. DNS rebinding protection: a hostile page can rebind its
|
|
93
112
|
* own name to 127.0.0.1, but cannot make the browser send a Host it does not own.
|
|
94
113
|
*
|
|
95
|
-
* - Open mode (no bearer token, no OIDC): the loopback names, `allowedHosts`,
|
|
114
|
+
* - Open mode (no bearer token, no OIDC): the loopback names, `allowedHosts`, the
|
|
96
115
|
* bind address itself when it is a concrete, non-wildcard address (an IP-literal
|
|
97
|
-
* Host cannot come out of rebinding)
|
|
116
|
+
* Host cannot come out of rebinding), and (#615) the advertised host, the address
|
|
117
|
+
* the server prints as its own URL (MCP_BRIDGE_PUBLIC_HOST or the detected LAN
|
|
118
|
+
* IP): an operator-controlled name or an IP literal, never one a hostile page can
|
|
119
|
+
* make the browser send. Not on a loopback bind, which the LAN cannot reach.
|
|
120
|
+
* IPv6 values are added bracketed and canonical.
|
|
98
121
|
* - Authenticated: `null` when `allowedHosts` is empty (unchanged behaviour), else
|
|
99
|
-
* `allowedHosts` plus the loopback names.
|
|
122
|
+
* `allowedHosts` plus the loopback names. The bind and advertised hosts are not
|
|
123
|
+
* added: an operator list stays exact.
|
|
100
124
|
*
|
|
101
125
|
* Pure: `allowedHosts` is the already-validated MCP_ALLOWED_HOSTS list.
|
|
102
126
|
*/
|
|
103
|
-
export function resolveAllowedHosts({ openMode, allowedHosts, bindHost, }) {
|
|
127
|
+
export function resolveAllowedHosts({ openMode, allowedHosts, bindHost, advertisedHost, }) {
|
|
104
128
|
if (!openMode && allowedHosts.length === 0)
|
|
105
129
|
return null;
|
|
106
130
|
const hosts = new Set([...LOOPBACK_HOSTNAMES, ...allowedHosts]);
|
|
107
|
-
if (openMode
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
canonical = new URL(`http://${bracketed}`).hostname;
|
|
116
|
-
}
|
|
117
|
-
catch { /* keep as typed */ }
|
|
118
|
-
hosts.add(canonical);
|
|
131
|
+
if (openMode) {
|
|
132
|
+
// A loopback bind is unreachable from the LAN, so the advertised (LAN) host is
|
|
133
|
+
// left out there: adding it would only widen which Origins may call localhost.
|
|
134
|
+
const advertised = isLoopbackHost(bindHost) ? undefined : advertisedHost;
|
|
135
|
+
for (const h of [canonicalHostname(bindHost), canonicalHostname(advertised)]) {
|
|
136
|
+
if (h)
|
|
137
|
+
hosts.add(h);
|
|
119
138
|
}
|
|
120
139
|
}
|
|
121
140
|
return [...hosts];
|
|
@@ -94,7 +94,21 @@ advertisedUrl) {
|
|
|
94
94
|
// #612: DNS rebinding protection, mounted first so it covers every route (/health,
|
|
95
95
|
// /metrics, /.well-known/* and OPTIONS included). It reads req.headers.host only,
|
|
96
96
|
// never X-Forwarded-Host or req.hostname, so a proxy header cannot bypass it.
|
|
97
|
-
|
|
97
|
+
// #615: the host this server prints as its own URL is accepted too (open mode only).
|
|
98
|
+
let advertisedHost;
|
|
99
|
+
try {
|
|
100
|
+
advertisedHost = advertisedUrl ? new URL(advertisedUrl).hostname : undefined;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
logger.warn(`[HTTP] Advertised URL ${advertisedUrl} does not parse; its host is not accepted. Check MCP_BRIDGE_PUBLIC_HOST (a bare host or IP, no scheme or port).`);
|
|
104
|
+
}
|
|
105
|
+
// MCP_BRIDGE_PUBLIC_HOST=https://nas.lan parses to the hostname "https": say so rather than
|
|
106
|
+
// accept it silently. A scheme or path always carries a "/"; a port already fails the parse.
|
|
107
|
+
const publicHost = process.env.MCP_BRIDGE_PUBLIC_HOST?.trim().toLowerCase();
|
|
108
|
+
if (advertisedHost && publicHost?.includes('/')) {
|
|
109
|
+
logger.warn(`[HTTP] MCP_BRIDGE_PUBLIC_HOST (${publicHost}) did not parse to a host name (got ${advertisedHost}). Set a bare host or IP, no scheme or port.`);
|
|
110
|
+
}
|
|
111
|
+
const allowedHosts = resolveAllowedHosts({ openMode, allowedHosts: config.MCP_ALLOWED_HOSTS, bindHost, advertisedHost });
|
|
98
112
|
if (allowedHosts) {
|
|
99
113
|
const rejectHost = (req, reason, value) => logger.warn('[HTTP] Rejected request: hostname not allowed (DNS rebinding protection, #612)', {
|
|
100
114
|
header: reason, hostname: value.slice(0, 253), path: req.path,
|