Skip to content

Commit 395c121

Browse files
committed
Harden inspector dev endpoints against CSRF, DNS rebinding, and traversal
- Reject cross-origin requests on every /__sunpeak/* endpoint via an Origin/Host check, closing the CSRF path that combined with the /__sunpeak/connect handler to allow arbitrary stdio command execution from any web page or untrusted iframe. - /__sunpeak/connect now refuses non-http(s) URLs, so the spawn-based stdio code path is reachable only from the CLI. - Bind the Vite dev server and sandbox proxy to 127.0.0.1 by default (override with SUNPEAK_HOST) and use Vite's loopback allowedHosts default (override with SUNPEAK_ALLOWED_HOSTS) so the inspector is no longer exposed on the LAN and DNS-rebinding requests get a 403. - Constrain the /dist/*.html static handler to <projectRoot>/dist, removing the path traversal that exposed any readable .html file. - Validate the upstream-supplied mimeType before reflecting it into the Content-Type header. - Block javascript:/data: URLs and add noopener+noreferrer in openExternal (mock window.openai) and the MCP Apps openLink fallback, so a malicious app can't run script in the inspector's origin or pivot back through window.opener. - Restrict the resource appIcon <img> path to http(s) and raster data:image/* URIs, blocking SVG-borne XSS vectors. - ALLOWED_SCRIPT_ORIGINS now only honors localhost when the inspector is itself on loopback, preventing a hosted inspector from being coerced into loading scripts off the visitor's machine.
1 parent b2d0555 commit 395c121

12 files changed

Lines changed: 190 additions & 28 deletions

File tree

examples/albums-example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"clsx": "^2.1.1",
1919
"embla-carousel-react": "^8.6.0",
2020
"embla-carousel-wheel-gestures": "^8.1.0",
21-
"sunpeak": "^0.20.17",
21+
"sunpeak": "^0.20.18",
2222
"tailwind-merge": "^3.5.0",
2323
"zod": "^4.4.3"
2424
},

examples/carousel-example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"clsx": "^2.1.1",
1919
"embla-carousel-react": "^8.6.0",
2020
"embla-carousel-wheel-gestures": "^8.1.0",
21-
"sunpeak": "^0.20.17",
21+
"sunpeak": "^0.20.18",
2222
"tailwind-merge": "^3.5.0",
2323
"zod": "^4.4.3"
2424
},

examples/map-example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"clsx": "^2.1.1",
1919
"embla-carousel-react": "^8.6.0",
2020
"mapbox-gl": "^3.23.1",
21-
"sunpeak": "^0.20.17",
21+
"sunpeak": "^0.20.18",
2222
"tailwind-merge": "^3.5.0",
2323
"zod": "^4.4.3"
2424
},

examples/review-example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
},
1717
"dependencies": {
1818
"clsx": "^2.1.1",
19-
"sunpeak": "^0.20.17",
19+
"sunpeak": "^0.20.18",
2020
"tailwind-merge": "^3.5.0",
2121
"zod": "^4.4.3"
2222
},

packages/sunpeak/bin/commands/inspect.mjs

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import * as fs from 'fs';
1717
import * as path from 'path';
1818
const { existsSync, readdirSync, readFileSync } = fs;
19-
const { join, resolve, dirname } = path;
19+
const { join, resolve, dirname, sep } = path;
2020
import { fileURLToPath, pathToFileURL } from 'url';
2121
import { createServer as createHttpServer } from 'http';
2222
import { getPort } from '../lib/get-port.mjs';
@@ -716,11 +716,40 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
716716
// and clientInformation).
717717
/** @type {Map<string, { serverUrl: string, oauthState: any }>} */
718718
const pendingOAuthFlows = new Map();
719+
/**
720+
* Reject requests where the Origin header doesn't match the Host header.
721+
* This blocks browser-issued cross-origin requests (CSRF) and DNS rebinding
722+
* attacks that would otherwise reach the privileged /__sunpeak/* endpoints.
723+
* Requests without an Origin header (curl, Node fetch without origin) are
724+
* allowed because they cannot be triggered cross-origin from a browser.
725+
* @param {import('http').IncomingMessage} req
726+
* @param {import('http').ServerResponse} res
727+
*/
728+
function requireSameOrigin(req, res) {
729+
const origin = req.headers.origin;
730+
if (!origin) return true;
731+
let originHost;
732+
try {
733+
originHost = new URL(origin).host;
734+
} catch {
735+
res.writeHead(403, { 'Content-Type': 'application/json' });
736+
res.end(JSON.stringify({ error: 'Forbidden: invalid Origin header' }));
737+
return false;
738+
}
739+
if (originHost !== req.headers.host) {
740+
res.writeHead(403, { 'Content-Type': 'application/json' });
741+
res.end(JSON.stringify({ error: 'Forbidden: cross-origin request blocked' }));
742+
return false;
743+
}
744+
return true;
745+
}
746+
719747
return {
720748
name: 'sunpeak-inspect-endpoints',
721749
configureServer(server) {
722750
// List tools from connected server (with automatic session recovery)
723-
server.middlewares.use('/__sunpeak/list-tools', async (_req, res) => {
751+
server.middlewares.use('/__sunpeak/list-tools', async (req, res) => {
752+
if (!requireSameOrigin(req, res)) return;
724753
try {
725754
const client = getClient();
726755
const result = await client.listTools();
@@ -742,7 +771,8 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
742771
});
743772

744773
// List resources from connected server
745-
server.middlewares.use('/__sunpeak/list-resources', async (_req, res) => {
774+
server.middlewares.use('/__sunpeak/list-resources', async (req, res) => {
775+
if (!requireSameOrigin(req, res)) return;
746776
try {
747777
const client = getClient();
748778
const result = await client.listResources();
@@ -757,6 +787,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
757787

758788
// Call tool on connected server
759789
server.middlewares.use('/__sunpeak/call-tool', async (req, res) => {
790+
if (!requireSameOrigin(req, res)) return;
760791
if (req.method !== 'POST') {
761792
res.writeHead(405);
762793
res.end('Method not allowed');
@@ -804,6 +835,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
804835
// Used by the Prod Tools Run button so the real handler executes even
805836
// when the MCP server would return simulation fixture data.
806837
server.middlewares.use('/__sunpeak/call-tool-direct', async (req, res) => {
838+
if (!requireSameOrigin(req, res)) return;
807839
if (req.method !== 'POST') {
808840
res.writeHead(405);
809841
res.end('Method not allowed');
@@ -852,6 +884,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
852884
// Reconnect to a new MCP server URL.
853885
// Creates a new MCP client connection and replaces the current one.
854886
server.middlewares.use('/__sunpeak/connect', async (req, res) => {
887+
if (!requireSameOrigin(req, res)) return;
855888
if (req.method !== 'POST') {
856889
res.writeHead(405);
857890
res.end('Method not allowed');
@@ -883,6 +916,17 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
883916
return;
884917
}
885918

919+
// Only http(s) URLs are accepted via the HTTP endpoint. Stdio servers
920+
// (which spawn child processes) are reachable only by the CLI caller of
921+
// `inspectServer()`, never by an HTTP client — otherwise a malicious
922+
// page or untrusted app iframe could trigger arbitrary command
923+
// execution via this endpoint.
924+
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
925+
res.writeHead(400, { 'Content-Type': 'application/json' });
926+
res.end(JSON.stringify({ error: 'Only http(s) URLs are allowed' }));
927+
return;
928+
}
929+
886930
try {
887931
// Close old connection (best effort)
888932
try { await getClient().close(); } catch { /* ignore */ }
@@ -922,6 +966,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
922966

923967
// Start OAuth: discover metadata, register client, return authorization URL
924968
server.middlewares.use('/__sunpeak/oauth/start', async (req, res) => {
969+
if (!requireSameOrigin(req, res)) return;
925970
if (req.method !== 'POST') {
926971
res.writeHead(405);
927972
res.end('Method not allowed');
@@ -1108,6 +1153,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
11081153

11091154
// Complete OAuth: exchange authorization code for tokens and connect
11101155
server.middlewares.use('/__sunpeak/oauth/complete', async (req, res) => {
1156+
if (!requireSameOrigin(req, res)) return;
11111157
if (req.method !== 'POST') {
11121158
res.writeHead(405);
11131159
res.end('Method not allowed');
@@ -1187,6 +1233,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
11871233

11881234
// Read resource from connected server
11891235
server.middlewares.use('/__sunpeak/read-resource', async (req, res) => {
1236+
if (!requireSameOrigin(req, res)) return;
11901237
const url = new URL(req.url, 'http://localhost');
11911238
const uri = url.searchParams.get('uri');
11921239
if (!uri) {
@@ -1205,7 +1252,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
12051252
return;
12061253
}
12071254

1208-
const mimeType = content.mimeType || 'text/html';
1255+
const mimeType = sanitizeMimeType(content.mimeType);
12091256
res.writeHead(200, {
12101257
'Content-Type': `${mimeType}; charset=utf-8`,
12111258
'X-Content-Type-Options': 'nosniff',
@@ -1235,7 +1282,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
12351282
const retryResult = await getClient().readResource({ uri });
12361283
const retryContent = retryResult.contents?.[0];
12371284
if (retryContent) {
1238-
const mimeType = retryContent.mimeType || 'text/html';
1285+
const mimeType = sanitizeMimeType(retryContent.mimeType);
12391286
res.writeHead(200, {
12401287
'Content-Type': `${mimeType}; charset=utf-8`,
12411288
'X-Content-Type-Options': 'nosniff',
@@ -1253,6 +1300,42 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
12531300
};
12541301
}
12551302

1303+
/**
1304+
* Parse the SUNPEAK_ALLOWED_HOSTS env var into a value Vite accepts for its
1305+
* `server.allowedHosts` option. Empty/undefined → use Vite's default
1306+
* (localhost loopback only). The literal string "all" maps to Vite's
1307+
* "allow everything" mode, which disables DNS-rebinding protection.
1308+
* Otherwise the value is split on commas and trimmed.
1309+
*
1310+
* @param {string | undefined} raw
1311+
*/
1312+
function parseAllowedHosts(raw) {
1313+
if (!raw) return undefined;
1314+
const trimmed = raw.trim();
1315+
if (!trimmed) return undefined;
1316+
if (trimmed === 'all') return 'all';
1317+
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
1318+
}
1319+
1320+
/**
1321+
* Validate and normalize a Content-Type value supplied by the upstream MCP
1322+
* server. The mimeType is reflected back into our HTTP response, so a
1323+
* malformed or unexpected value would let an attacker influence how callers
1324+
* interpret the response (e.g. force `text/html` rendering of opaque blobs).
1325+
*
1326+
* Accepts simple `type/subtype` shapes only (RFC 7231 token chars).
1327+
* Anything else falls back to `text/html`, which is the protocol's documented
1328+
* default mime type for resources that omit one.
1329+
*
1330+
* @param {unknown} mimeType
1331+
*/
1332+
function sanitizeMimeType(mimeType) {
1333+
if (typeof mimeType !== 'string' || mimeType.length === 0) return 'text/html';
1334+
// RFC 7231 token chars, no parameters/whitespace allowed here.
1335+
if (!/^[\w.+-]+\/[\w.+-]+$/.test(mimeType)) return 'text/html';
1336+
return mimeType;
1337+
}
1338+
12561339
/**
12571340
* Read the full body of an HTTP request.
12581341
*/
@@ -1493,9 +1576,21 @@ export async function inspectServer(opts) {
14931576
...(projectRoot ? [{
14941577
name: 'sunpeak-dist-serve',
14951578
configureServer(server) {
1579+
const distRoot = resolve(projectRoot, 'dist');
14961580
server.middlewares.use((req, res, next) => {
14971581
if (!req.url?.startsWith('/dist/') || !req.url.endsWith('.html')) return next();
1498-
const filePath = join(projectRoot, req.url);
1582+
// Strip query/hash before joining to avoid `?` or `#` confusing path parsers.
1583+
const pathOnly = req.url.split('?')[0].split('#')[0];
1584+
// Resolve the target path and require it to stay inside `<projectRoot>/dist`.
1585+
// Without this, a request like `/dist/../../etc/anything.html` would resolve
1586+
// outside the project and serve arbitrary readable files as HTML.
1587+
const filePath = resolve(projectRoot, pathOnly.replace(/^\/+/, ''));
1588+
const distRootWithSep = distRoot.endsWith(sep) ? distRoot : distRoot + sep;
1589+
if (filePath !== distRoot && !filePath.startsWith(distRootWithSep)) {
1590+
res.writeHead(403);
1591+
res.end('Forbidden');
1592+
return;
1593+
}
14991594
if (existsSync(filePath)) {
15001595
const content = readFileSync(filePath, 'utf-8');
15011596
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -1574,15 +1669,17 @@ export async function inspectServer(opts) {
15741669
// ERR_CONNECTION_REFUSED. When auto-discovered via getPort(), the port is
15751670
// already free so this doesn't apply.
15761671
...(explicitPort ? { strictPort: true } : {}),
1577-
// Listen on all interfaces so both 127.0.0.1 (used by Playwright tests)
1578-
// and localhost (used by interactive browsing) connect successfully.
1579-
// Without this, Vite defaults to localhost which may resolve to IPv6-only
1580-
// (::1) on macOS, causing ECONNREFUSED for IPv4 clients.
1581-
host: '0.0.0.0',
1582-
// Allow any hostname so the inspector works behind tunnels, in containers,
1583-
// and with custom /etc/hosts entries. Without this, Vite 8's DNS rebinding
1584-
// protection blocks requests whose Host header isn't localhost/127.0.0.1.
1585-
allowedHosts: 'all',
1672+
// Bind to 127.0.0.1 by default so the inspector is not reachable from the
1673+
// LAN. The /__sunpeak/* endpoints can call the connected MCP server, so
1674+
// exposing them on 0.0.0.0 lets any device on the same network drive the
1675+
// developer's tools. Set SUNPEAK_HOST=0.0.0.0 (or another address) to opt in.
1676+
host: process.env.SUNPEAK_HOST || '127.0.0.1',
1677+
// Vite's DNS-rebinding protection rejects requests whose Host header
1678+
// isn't in this allowlist, which closes the residual rebinding attack
1679+
// even when the server is bound to 0.0.0.0. Set SUNPEAK_ALLOWED_HOSTS
1680+
// (comma-separated, or "all") to allow tunnels, containers, or custom
1681+
// /etc/hosts entries.
1682+
allowedHosts: parseAllowedHosts(process.env.SUNPEAK_ALLOWED_HOSTS),
15861683
open: open ?? (!process.env.CI && !process.env.SUNPEAK_LIVE_TEST),
15871684
},
15881685
optimizeDeps: {

packages/sunpeak/bin/lib/sandbox-server.mjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,14 @@ export async function startSandboxServer({ preferredPort = 24680 } = {}) {
8181
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
8282
});
8383

84+
// Bind to loopback by default so the proxy server is not reachable from the
85+
// LAN. The proxy serves iframe HTML that brokers PostMessage between host
86+
// and app — exposing it on 0.0.0.0 lets any LAN device serve crafted
87+
// postMessage relays into the developer's browser. Override with
88+
// SUNPEAK_HOST when LAN access is intentional.
89+
const bindHost = process.env.SUNPEAK_HOST || '127.0.0.1';
8490
await new Promise((resolve, reject) => {
85-
server.listen(port, () => resolve());
91+
server.listen(port, bindHost, () => resolve());
8692
server.on('error', reject);
8793
});
8894

@@ -342,6 +348,6 @@ const MOCK_OPENAI_SCRIPT = [
342348
'requestDisplayMode:function(p){console.log("[Inspector] requestDisplayMode:",p.mode);',
343349
'return Promise.resolve()},',
344350
'sendFollowUpMessage:function(p){console.log("[Inspector] sendFollowUpMessage:",p.prompt)},',
345-
'openExternal:function(p){console.log("[Inspector] openExternal:",p.href);window.open(p.href,"_blank")}',
351+
'openExternal:function(p){console.log("[Inspector] openExternal:",p.href);try{var u=new URL(p.href);if(u.protocol!=="http:"&&u.protocol!=="https:"){console.warn("[Inspector] openExternal blocked non-http(s) URL:",p.href);return}window.open(p.href,"_blank","noopener,noreferrer")}catch(e){console.warn("[Inspector] openExternal blocked invalid URL:",p.href)}}',
346352
'};',
347353
].join('');

packages/sunpeak/src/chatgpt/chatgpt-conversation.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react';
22
import { useEffect, useRef, useCallback } from 'react';
33
import { SCREEN_WIDTHS, type ScreenWidth } from '../inspector/inspector-types';
4+
import { isAllowedIconUrl } from '../lib/utils';
45
import type { McpUiDisplayMode, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
56

67
type Platform = NonNullable<McpUiHostContext['platform']>;
@@ -219,7 +220,7 @@ export function Conversation({
219220
{!isFullscreen && (
220221
<div className="flex items-center gap-2 my-3">
221222
{appIcon ? (
222-
appIcon.startsWith('data:') || appIcon.startsWith('http') ? (
223+
isAllowedIconUrl(appIcon) ? (
223224
<img src={appIcon} alt="" className="size-6 rounded-full object-cover" />
224225
) : (
225226
<div className="size-6 flex items-center justify-center text-base">

packages/sunpeak/src/claude/claude-conversation.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react';
22
import { useEffect, useRef, useCallback } from 'react';
33
import { SCREEN_WIDTHS, type ScreenWidth } from '../inspector/inspector-types';
4+
import { isAllowedIconUrl } from '../lib/utils';
45
import type { McpUiDisplayMode, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
56

67
type Platform = NonNullable<McpUiHostContext['platform']>;
@@ -216,7 +217,7 @@ export function ClaudeConversation({
216217
{!isFullscreen && (
217218
<div className="flex items-center gap-2 mb-3">
218219
{appIcon ? (
219-
appIcon.startsWith('data:') || appIcon.startsWith('http') ? (
220+
isAllowedIconUrl(appIcon) ? (
220221
<img src={appIcon} alt="" className="size-6 rounded-full object-cover" />
221222
) : (
222223
<div className="size-6 flex items-center justify-center text-base">

packages/sunpeak/src/inspector/iframe-resource.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ function escapeHtml(str: string): string {
3535
});
3636
}
3737

38+
/**
39+
* Returns true when the inspector itself is running on a loopback hostname.
40+
* Used to decide whether to honor `localhost`/`127.0.0.1` script URLs supplied
41+
* by the connected MCP server. When the inspector is hosted on a public domain,
42+
* a remote MCP server should not be able to coerce the inspector into loading
43+
* scripts from arbitrary services running on the visitor's machine
44+
* (e.g. http://127.0.0.1:11434/...).
45+
*/
46+
function isInspectorOnLoopback(): boolean {
47+
if (typeof window === 'undefined') return true;
48+
const host = window.location.hostname;
49+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1';
50+
}
51+
3852
/**
3953
* Validates that a URL is from an allowed origin.
4054
* Allows same-origin URLs and URLs from whitelisted domains.
@@ -54,8 +68,12 @@ function isAllowedUrl(src: string): boolean {
5468
// Allow same-origin
5569
if (url.origin === window.location.origin) return true;
5670

57-
// Allow localhost with any port for development
58-
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
71+
// Allow localhost with any port — but only when the inspector itself is
72+
// running on loopback. Otherwise a hosted inspector would happily load
73+
// scripts from the visitor's local machine.
74+
if ((url.hostname === 'localhost' || url.hostname === '127.0.0.1') && isInspectorOnLoopback()) {
75+
return true;
76+
}
5977

6078
// Check against allowed origins (strict origin comparison only)
6179
return ALLOWED_SCRIPT_ORIGINS.some((allowed) => {

packages/sunpeak/src/inspector/mcp-app-host.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,10 @@ export class McpAppHost {
119119
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
120120
console.warn('[MCP App] openLink blocked non-http(s) URL:', url);
121121
} else {
122-
window.open(url, '_blank');
122+
// noopener+noreferrer prevents the new tab from reaching this
123+
// window via window.opener (Safari does not auto-apply this for
124+
// same-origin destinations).
125+
window.open(url, '_blank', 'noopener,noreferrer');
123126
}
124127
} catch {
125128
console.warn('[MCP App] openLink blocked invalid URL:', url);

0 commit comments

Comments
 (0)