sunpeak 0.20.18 → 0.20.19

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.
@@ -16,7 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  const { existsSync, readdirSync, readFileSync } = fs;
19
- const { join, resolve, dirname } = path;
19
+ const { join, resolve, dirname, sep } = path;
20
20
  import { fileURLToPath, pathToFileURL } from 'url';
21
21
  import { createServer as createHttpServer } from 'http';
22
22
  import { getPort } from '../lib/get-port.mjs';
@@ -716,11 +716,40 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
716
716
  // and clientInformation).
717
717
  /** @type {Map<string, { serverUrl: string, oauthState: any }>} */
718
718
  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
+
719
747
  return {
720
748
  name: 'sunpeak-inspect-endpoints',
721
749
  configureServer(server) {
722
750
  // 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;
724
753
  try {
725
754
  const client = getClient();
726
755
  const result = await client.listTools();
@@ -742,7 +771,8 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
742
771
  });
743
772
 
744
773
  // 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;
746
776
  try {
747
777
  const client = getClient();
748
778
  const result = await client.listResources();
@@ -757,6 +787,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
757
787
 
758
788
  // Call tool on connected server
759
789
  server.middlewares.use('/__sunpeak/call-tool', async (req, res) => {
790
+ if (!requireSameOrigin(req, res)) return;
760
791
  if (req.method !== 'POST') {
761
792
  res.writeHead(405);
762
793
  res.end('Method not allowed');
@@ -804,6 +835,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
804
835
  // Used by the Prod Tools Run button so the real handler executes even
805
836
  // when the MCP server would return simulation fixture data.
806
837
  server.middlewares.use('/__sunpeak/call-tool-direct', async (req, res) => {
838
+ if (!requireSameOrigin(req, res)) return;
807
839
  if (req.method !== 'POST') {
808
840
  res.writeHead(405);
809
841
  res.end('Method not allowed');
@@ -852,6 +884,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
852
884
  // Reconnect to a new MCP server URL.
853
885
  // Creates a new MCP client connection and replaces the current one.
854
886
  server.middlewares.use('/__sunpeak/connect', async (req, res) => {
887
+ if (!requireSameOrigin(req, res)) return;
855
888
  if (req.method !== 'POST') {
856
889
  res.writeHead(405);
857
890
  res.end('Method not allowed');
@@ -883,6 +916,17 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
883
916
  return;
884
917
  }
885
918
 
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
+
886
930
  try {
887
931
  // Close old connection (best effort)
888
932
  try { await getClient().close(); } catch { /* ignore */ }
@@ -922,6 +966,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
922
966
 
923
967
  // Start OAuth: discover metadata, register client, return authorization URL
924
968
  server.middlewares.use('/__sunpeak/oauth/start', async (req, res) => {
969
+ if (!requireSameOrigin(req, res)) return;
925
970
  if (req.method !== 'POST') {
926
971
  res.writeHead(405);
927
972
  res.end('Method not allowed');
@@ -1108,6 +1153,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
1108
1153
 
1109
1154
  // Complete OAuth: exchange authorization code for tokens and connect
1110
1155
  server.middlewares.use('/__sunpeak/oauth/complete', async (req, res) => {
1156
+ if (!requireSameOrigin(req, res)) return;
1111
1157
  if (req.method !== 'POST') {
1112
1158
  res.writeHead(405);
1113
1159
  res.end('Method not allowed');
@@ -1187,6 +1233,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
1187
1233
 
1188
1234
  // Read resource from connected server
1189
1235
  server.middlewares.use('/__sunpeak/read-resource', async (req, res) => {
1236
+ if (!requireSameOrigin(req, res)) return;
1190
1237
  const url = new URL(req.url, 'http://localhost');
1191
1238
  const uri = url.searchParams.get('uri');
1192
1239
  if (!uri) {
@@ -1205,7 +1252,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
1205
1252
  return;
1206
1253
  }
1207
1254
 
1208
- const mimeType = content.mimeType || 'text/html';
1255
+ const mimeType = sanitizeMimeType(content.mimeType);
1209
1256
  res.writeHead(200, {
1210
1257
  'Content-Type': `${mimeType}; charset=utf-8`,
1211
1258
  'X-Content-Type-Options': 'nosniff',
@@ -1235,7 +1282,7 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
1235
1282
  const retryResult = await getClient().readResource({ uri });
1236
1283
  const retryContent = retryResult.contents?.[0];
1237
1284
  if (retryContent) {
1238
- const mimeType = retryContent.mimeType || 'text/html';
1285
+ const mimeType = sanitizeMimeType(retryContent.mimeType);
1239
1286
  res.writeHead(200, {
1240
1287
  'Content-Type': `${mimeType}; charset=utf-8`,
1241
1288
  'X-Content-Type-Options': 'nosniff',
@@ -1253,6 +1300,42 @@ function sunpeakInspectEndpointsPlugin(getClient, setClient, pluginOpts = {}) {
1253
1300
  };
1254
1301
  }
1255
1302
 
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
+
1256
1339
  /**
1257
1340
  * Read the full body of an HTTP request.
1258
1341
  */
@@ -1493,9 +1576,21 @@ export async function inspectServer(opts) {
1493
1576
  ...(projectRoot ? [{
1494
1577
  name: 'sunpeak-dist-serve',
1495
1578
  configureServer(server) {
1579
+ const distRoot = resolve(projectRoot, 'dist');
1496
1580
  server.middlewares.use((req, res, next) => {
1497
1581
  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
+ }
1499
1594
  if (existsSync(filePath)) {
1500
1595
  const content = readFileSync(filePath, 'utf-8');
1501
1596
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -1574,15 +1669,17 @@ export async function inspectServer(opts) {
1574
1669
  // ERR_CONNECTION_REFUSED. When auto-discovered via getPort(), the port is
1575
1670
  // already free so this doesn't apply.
1576
1671
  ...(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),
1586
1683
  open: open ?? (!process.env.CI && !process.env.SUNPEAK_LIVE_TEST),
1587
1684
  },
1588
1685
  optimizeDeps: {
@@ -81,8 +81,14 @@ export async function startSandboxServer({ preferredPort = 24680 } = {}) {
81
81
  socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
82
82
  });
83
83
 
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';
84
90
  await new Promise((resolve, reject) => {
85
- server.listen(port, () => resolve());
91
+ server.listen(port, bindHost, () => resolve());
86
92
  server.on('error', reject);
87
93
  });
88
94
 
@@ -342,6 +348,6 @@ const MOCK_OPENAI_SCRIPT = [
342
348
  'requestDisplayMode:function(p){console.log("[Inspector] requestDisplayMode:",p.mode);',
343
349
  'return Promise.resolve()},',
344
350
  '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)}}',
346
352
  '};',
347
353
  ].join('');
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../chunk-CoPdw6nB.cjs");
3
- const require_inspector = require("../inspector-Dnxjqhu3.cjs");
3
+ const require_inspector = require("../inspector-BLOY4Qj6.cjs");
4
4
  const require_inspector_url = require("../inspector-url-CCgv8H74.cjs");
5
5
  const require_discovery = require("../discovery-C9fQVb1u.cjs");
6
6
  //#region src/chatgpt/index.ts
@@ -1,5 +1,5 @@
1
1
  import { I as __exportAll } from "../v4-COy4jjxX.js";
2
- import { _ as McpAppHost, d as ThemeProvider, f as useThemeContext, g as extractResourceCSP, h as IframeResource, n as resolveServerToolResult, t as Inspector, v as SCREEN_WIDTHS } from "../inspector-l1Eo18H7.js";
2
+ import { _ as McpAppHost, d as ThemeProvider, f as useThemeContext, g as extractResourceCSP, h as IframeResource, n as resolveServerToolResult, t as Inspector, v as SCREEN_WIDTHS } from "../inspector-DMdmJlDA.js";
3
3
  import { t as createInspectorUrl } from "../inspector-url-CyQcuBI9.js";
4
4
  import { c as toPascalCase, i as findResourceKey, n as extractSimulationKey, r as findResourceDirs, s as getComponentName, t as extractResourceKey } from "../discovery-Cgoegt62.js";
5
5
  //#region src/chatgpt/index.ts
@@ -1,4 +1,4 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  require("../chunk-CoPdw6nB.cjs");
3
- const require_inspector = require("../inspector-Dnxjqhu3.cjs");
3
+ const require_inspector = require("../inspector-BLOY4Qj6.cjs");
4
4
  exports.Inspector = require_inspector.Inspector;
@@ -1,2 +1,2 @@
1
- import { t as Inspector } from "../inspector-l1Eo18H7.js";
1
+ import { t as Inspector } from "../inspector-DMdmJlDA.js";
2
2
  export { Inspector };