1616import * as fs from 'fs' ;
1717import * as path from 'path' ;
1818const { existsSync, readdirSync, readFileSync } = fs ;
19- const { join, resolve, dirname } = path ;
19+ const { join, resolve, dirname, sep } = path ;
2020import { fileURLToPath , pathToFileURL } from 'url' ;
2121import { createServer as createHttpServer } from 'http' ;
2222import { 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' || ! / ^ h t t p s ? : \/ \/ / 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 : {
0 commit comments