@luanpdd/kit-mcp 1.13.0 → 1.14.0

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/src/ui/server.js CHANGED
@@ -22,6 +22,7 @@ import { readFileSync } from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import process from 'node:process';
25
+ import { createHash } from 'node:crypto';
25
26
 
26
27
  import { findFreePortOrThrow } from './port.js';
27
28
  import { acquireLockOrReclaim, releaseLock } from './lockfile.js';
@@ -42,19 +43,70 @@ const SSE_HEADERS = {
42
43
  'X-Accel-Buffering': 'no',
43
44
  };
44
45
 
45
- const CSP =
46
- "default-src 'self'; " +
47
- "connect-src 'self'; " +
48
- "script-src 'self' 'unsafe-inline'; " +
49
- "style-src 'self' 'unsafe-inline'; " +
50
- "img-src 'self' data:; " +
51
- "frame-ancestors 'none'";
46
+ // SEC-14-01: CSP without 'unsafe-inline' in script-src. The single inline
47
+ // <script> block in index.html is allowed via SHA-256 hash injected at boot.
48
+ // 'unsafe-inline' kept ONLY for style-src (the entire <style> block is intentional;
49
+ // CSS injection has no script execution vector with connect-src 'self').
50
+ function buildCsp(scriptHash) {
51
+ const scriptSrc = scriptHash ? `'self' ${scriptHash}` : "'self'";
52
+ return (
53
+ "default-src 'self'; " +
54
+ "connect-src 'self'; " +
55
+ `script-src ${scriptSrc}; ` +
56
+ "style-src 'self' 'unsafe-inline'; " +
57
+ "img-src 'self' data:; " +
58
+ "frame-ancestors 'none'"
59
+ );
60
+ }
61
+
62
+ // Computes the SHA-256 hash of the inline <script> block in the static HTML.
63
+ // Returns the CSP-formatted source expression: "'sha256-<base64>='".
64
+ // Returns empty string if no <script> block found (graceful — caller falls back to "'self'" alone).
65
+ function computeScriptHashFromHtml(html) {
66
+ if (typeof html !== 'string') return '';
67
+ const m = html.match(/<script>([\s\S]*?)<\/script>/);
68
+ if (!m) return '';
69
+ const hash = createHash('sha256').update(m[1], 'utf8').digest('base64');
70
+ return `'sha256-${hash}'`;
71
+ }
52
72
 
53
73
  function logErr(...args) {
54
74
  // Strict stderr discipline — never stdout (collides with MCP JSON-RPC if running in same process).
55
75
  process.stderr.write(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
56
76
  }
57
77
 
78
+ // SEC-14-02: per-process auth token. Set during start() from acquireLock result.
79
+ // Cleared on shutdown(). Never logged in full.
80
+ let authToken = null;
81
+
82
+ // requireAuth: returns true if request has a valid token via either:
83
+ // - Authorization: Bearer <token> (preferred for fetch from same-origin browser)
84
+ // - ?t=<token> query param (required for EventSource — browser API can't set headers)
85
+ // Caller is responsible for sending 401 when this returns false.
86
+ function requireAuth(req, url) {
87
+ if (!authToken) return false; // server didn't init token — fail closed
88
+ const auth = req.headers.authorization;
89
+ if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
90
+ const provided = auth.slice('Bearer '.length).trim();
91
+ if (timingSafeEqual(provided, authToken)) return true;
92
+ }
93
+ const qp = url?.searchParams?.get('t');
94
+ if (typeof qp === 'string' && timingSafeEqual(qp, authToken)) return true;
95
+ return false;
96
+ }
97
+
98
+ // Constant-time string comparison to prevent timing-leak side channel.
99
+ // Walks the longer of the two strings even when lengths differ to keep timing flat.
100
+ function timingSafeEqual(a, b) {
101
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
102
+ const max = Math.max(a.length, b.length);
103
+ let diff = a.length ^ b.length;
104
+ for (let i = 0; i < max; i++) {
105
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
106
+ }
107
+ return diff === 0;
108
+ }
109
+
58
110
  // Validate Host header against allowed hostnames (REQ SEC-01).
59
111
  // Allow 127.0.0.1 and localhost on whatever port we're on.
60
112
  function isHostAllowed(req, port) {
@@ -122,15 +174,22 @@ function readBody(req, maxBytes = 64 * 1024) {
122
174
  });
123
175
  }
124
176
 
177
+ let _cachedIndex = null; // { html, scriptHash }
125
178
  function loadStaticIndex() {
126
179
  // src/ui/static/index.html — written in Phase 14. We tolerate it missing in
127
180
  // unit tests by serving a placeholder so the server module is testable in isolation.
181
+ if (_cachedIndex) return _cachedIndex;
182
+ let html;
128
183
  try {
129
- return readFileSync(path.join(STATIC_DIR, 'index.html'), 'utf8');
184
+ html = readFileSync(path.join(STATIC_DIR, 'index.html'), 'utf8');
130
185
  } catch {
131
- return `<!doctype html><meta charset="utf-8"><title>kit-mcp sidecar</title>
186
+ html = `<!doctype html><meta charset="utf-8"><title>kit-mcp sidecar</title>
132
187
  <body><pre>UI not yet packaged. Run \`kit ui\` after Phase 14 is shipped.</pre></body>`;
133
188
  }
189
+ // SEC-14-01: hash inline <script> for CSP whitelist. Cache per-process.
190
+ const scriptHash = computeScriptHashFromHtml(html);
191
+ _cachedIndex = { html, scriptHash };
192
+ return _cachedIndex;
134
193
  }
135
194
 
136
195
  export function createServer({
@@ -224,9 +283,14 @@ export function createServer({
224
283
  try { releaseLock(projectRoot); } catch { /* noop */ }
225
284
  lockMeta = null;
226
285
  }
286
+ authToken = null; // SEC-14-02: clear so a re-start gets a fresh one
227
287
  }
228
288
 
229
- function handleEvents(req, res) {
289
+ function handleEvents(req, res, url) {
290
+ if (!requireAuth(req, url)) {
291
+ sendJson(res, 401, { error: 'auth_required' });
292
+ return;
293
+ }
230
294
  if (subscribers.size >= maxSubscribers) {
231
295
  sendJson(res, 503, { error: 'too_many_subscribers', max: maxSubscribers });
232
296
  return;
@@ -269,7 +333,11 @@ export function createServer({
269
333
  res.on('error', cleanup);
270
334
  }
271
335
 
272
- async function handlePublish(req, res) {
336
+ async function handlePublish(req, res, url) {
337
+ if (!requireAuth(req, url)) {
338
+ sendJson(res, 401, { error: 'auth_required' });
339
+ return;
340
+ }
273
341
  if (!isOriginAllowed(req, listeningPort)) {
274
342
  sendJson(res, 403, { error: 'origin_not_allowed' });
275
343
  return;
@@ -313,7 +381,11 @@ export function createServer({
313
381
 
314
382
  // PERF-05: optional pagination via ?offset=N&limit=M. No query → ring inteiro
315
383
  // (back-compat preservada). Out-of-range values clamp to bounds rather than 4xx.
316
- function handleState(res, url) {
384
+ function handleState(req, res, url) {
385
+ if (!requireAuth(req, url)) {
386
+ sendJson(res, 401, { error: 'auth_required' });
387
+ return;
388
+ }
317
389
  let events = ring;
318
390
  const offsetRaw = url?.searchParams?.get('offset');
319
391
  const limitRaw = url?.searchParams?.get('limit');
@@ -338,7 +410,11 @@ export function createServer({
338
410
  });
339
411
  }
340
412
 
341
- async function handleShutdownRequest(req, res) {
413
+ async function handleShutdownRequest(req, res, url) {
414
+ if (!requireAuth(req, url)) {
415
+ sendJson(res, 401, { error: 'auth_required' });
416
+ return;
417
+ }
342
418
  if (!isOriginAllowed(req, listeningPort)) {
343
419
  sendJson(res, 403, { error: 'origin_not_allowed' });
344
420
  return;
@@ -350,10 +426,16 @@ export function createServer({
350
426
  }
351
427
 
352
428
  function handleIndex(res) {
353
- const html = staticHtml ?? loadStaticIndex();
429
+ let html, scriptHash;
430
+ if (typeof staticHtml === 'string') {
431
+ html = staticHtml;
432
+ scriptHash = computeScriptHashFromHtml(staticHtml);
433
+ } else {
434
+ ({ html, scriptHash } = loadStaticIndex());
435
+ }
354
436
  res.writeHead(200, {
355
437
  'Content-Type': 'text/html; charset=utf-8',
356
- 'Content-Security-Policy': CSP,
438
+ 'Content-Security-Policy': buildCsp(scriptHash),
357
439
  'X-Content-Type-Options': 'nosniff',
358
440
  'Referrer-Policy': 'no-referrer',
359
441
  });
@@ -374,15 +456,15 @@ export function createServer({
374
456
  case 'GET /index.html':
375
457
  return handleIndex(res);
376
458
  case 'GET /events':
377
- return handleEvents(req, res);
459
+ return handleEvents(req, res, url);
378
460
  case 'GET /healthz':
379
461
  return handleHealthz(res);
380
462
  case 'GET /state':
381
- return handleState(res, url);
463
+ return handleState(req, res, url);
382
464
  case 'POST /publish':
383
- return handlePublish(req, res);
465
+ return handlePublish(req, res, url);
384
466
  case 'POST /shutdown':
385
- return handleShutdownRequest(req, res);
467
+ return handleShutdownRequest(req, res, url);
386
468
  default:
387
469
  return sendJson(res, 404, { error: 'not_found', route });
388
470
  }
@@ -400,6 +482,11 @@ export function createServer({
400
482
  version,
401
483
  startedAt,
402
484
  });
485
+ // SEC-14-02: copy per-process token from lockfile into closure for requireAuth.
486
+ authToken = lockMeta.token;
487
+ if (typeof authToken !== 'string' || authToken.length !== 64) {
488
+ throw new Error('SEC-14-02: lockMeta.token missing or malformed; refusing to start');
489
+ }
403
490
  server = http.createServer(handleRequest);
404
491
  server.on('connection', (sock) => {
405
492
  activeSockets.add(sock);
@@ -449,6 +536,12 @@ export const __test = {
449
536
  MAX_SSE_SUBSCRIBERS,
450
537
  DEFAULT_IDLE_MS,
451
538
  HEARTBEAT_INTERVAL_MS,
452
- CSP,
539
+ // SEC-14-01: CSP is now built dynamically with sha256 hash of inline <script>.
540
+ // The constant CSP no longer exists; tests should use buildCsp(scriptHash).
541
+ buildCsp,
542
+ computeScriptHashFromHtml,
453
543
  EVENT_TYPES,
544
+ // SEC-14-02: timingSafeEqual exposed for unit tests; requireAuth depends on
545
+ // closure state (authToken) so end-to-end HTTP tests verify behavior.
546
+ timingSafeEqual,
454
547
  };
@@ -1175,11 +1175,51 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
1175
1175
 
1176
1176
  <script>
1177
1177
  /* ──────────────────────────────────────────────────────────
1178
- kit-mcp sidecar — prototype
1179
- This is a faithful mock of what the production HTML will do.
1180
- In production, replace the MockSource with a real EventSource('/events').
1178
+ kit-mcp sidecar — production client
1179
+ SEC-14-01 (Phase 82): All event-derived content rendered via escapeHtml()
1180
+ before insertion into innerHTML. CSP without 'unsafe-inline' in script-src
1181
+ is primary defense; escapeHtml() is defense-in-depth.
1182
+ When adding new innerHTML sites, always escape dynamic fields.
1181
1183
  ────────────────────────────────────────────────────────── */
1182
1184
 
1185
+ /* ──────────────────────────────────────────────────────────
1186
+ SEC-14-02 (Phase 82 / Plan 02): auth token from URL query param.
1187
+ Server (Plan 01) requires Bearer token on /publish, /shutdown, /state
1188
+ and ?t= on /events. This block extracts ?t= once at boot, scrubs it
1189
+ from the address bar (so it doesn't leak via screen-share / browser
1190
+ history copy-paste), and exposes helpers for fetch + EventSource.
1191
+ Variable scoped to closure (not sessionStorage) — reload re-handshakes.
1192
+ ────────────────────────────────────────────────────────── */
1193
+ const __sidecarToken = (() => {
1194
+ try {
1195
+ const params = new URLSearchParams(window.location.search);
1196
+ const t = params.get("t");
1197
+ if (t && /^[0-9a-f]{64}$/.test(t)) {
1198
+ // Scrub from URL so re-share / screenshot doesn't leak it.
1199
+ params.delete("t");
1200
+ const newSearch = params.toString();
1201
+ const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
1202
+ window.history.replaceState(null, "", newUrl);
1203
+ return t;
1204
+ }
1205
+ } catch (_) { /* fall through */ }
1206
+ return null;
1207
+ })();
1208
+
1209
+ function authedFetch(input, init = {}) {
1210
+ const opts = { ...init, headers: { ...(init.headers || {}) } };
1211
+ if (__sidecarToken) {
1212
+ opts.headers["Authorization"] = "Bearer " + __sidecarToken;
1213
+ }
1214
+ return fetch(input, opts);
1215
+ }
1216
+
1217
+ function authedEventSourceUrl(path) {
1218
+ if (!__sidecarToken) return path;
1219
+ const sep = path.includes("?") ? "&" : "?";
1220
+ return path + sep + "t=" + encodeURIComponent(__sidecarToken);
1221
+ }
1222
+
1183
1223
  /* ---------- humanize helpers (preserved API) ---------- */
1184
1224
  const TYPE_LABELS = {
1185
1225
  "run.start": "INICIADO",
@@ -1433,16 +1473,21 @@ function historyRowHtml(h) {
1433
1473
  return `<div class="hist-detail-row"><span class="pct">${escapeHtml(pct)}</span><span class="lbl">${escapeHtml(lbl)}</span>${tk ? `<span class="tok">${tk}t</span>` : ""}</div>`;
1434
1474
  })
1435
1475
  .join("");
1476
+ // SEC-14-01: every dynamic field escaped before injection into the
1477
+ // history-row template. status/statusGlyph/dur/when/tokens are computed
1478
+ // locally (string enum or pre-built HTML literal) so are safe by construction;
1479
+ // h.runId and h.eventsCount cross the publisher boundary and MUST be escaped
1480
+ // even though they should normally be benign — defense in depth.
1436
1481
  return `
1437
- <div class="hist-row" data-status="${status}" data-runid="${h.runId}">
1482
+ <div class="hist-row" data-status="${escapeHtml(status)}" data-runid="${escapeHtml(h.runId)}">
1438
1483
  <div class="hist-status">${statusGlyph}</div>
1439
1484
  <div class="hist-title">${escapeHtml(title)}</div>
1440
1485
  <div class="hist-when">${when}</div>
1441
1486
  <div class="hist-meta-row">
1442
1487
  <span><span class="num">${dur}</span> dur</span>
1443
1488
  <span>${tokens}</span>
1444
- <span><span class="num">${h.eventsCount || 0}</span> eventos</span>
1445
- <span class="num">id ${h.runId.slice(0,8)}</span>
1489
+ <span><span class="num">${escapeHtml(String(h.eventsCount || 0))}</span> eventos</span>
1490
+ <span class="num">id ${escapeHtml(String(h.runId).slice(0,8))}</span>
1446
1491
  </div>
1447
1492
  <div class="hist-detail">${detailRows || '<div class="hist-detail-row"><span class="lbl">(sem progresso registrado)</span></div>'}</div>
1448
1493
  </div>
@@ -1534,10 +1579,14 @@ function activeCardHtml(run) {
1534
1579
  const longRunning = elapsed > 30_000;
1535
1580
  const stepCount = run.total ? `${run.current}/${run.total}` : "";
1536
1581
  const showTokens = run.tokens > 0;
1582
+ // SEC-14-01: family/iconHref/title/stepLabel/stepCount/percent are computed
1583
+ // locally; runId crosses the publisher boundary and is escaped before any
1584
+ // injection (data-attribute or text). Defense in depth: even string enums
1585
+ // (family) get escaped to ensure regression resistance.
1537
1586
  return `
1538
- <article class="run-card" data-runid="${run.runId}">
1587
+ <article class="run-card" data-runid="${escapeHtml(run.runId)}">
1539
1588
  <div class="rc-head">
1540
- <div class="rc-icon" data-tool="${family}"><svg><use href="${iconHref}"/></svg></div>
1589
+ <div class="rc-icon" data-tool="${escapeHtml(family)}"><svg><use href="${escapeHtml(iconHref)}"/></svg></div>
1541
1590
  <div class="rc-title-block">
1542
1591
  <div class="rc-tool">${escapeHtml(safeStr(run.tool) || "processo")}</div>
1543
1592
  <div class="rc-title">${escapeHtml(title)}</div>
@@ -1556,7 +1605,7 @@ function activeCardHtml(run) {
1556
1605
  <div class="rc-step">
1557
1606
  <span class="glyph"><svg><use href="#i-spin"/></svg></span>
1558
1607
  <span class="rc-step-text">${escapeHtml(stepLabel)}</span>
1559
- ${stepCount ? `<span class="rc-step-count">${stepCount}</span>` : ""}
1608
+ ${stepCount ? `<span class="rc-step-count">${escapeHtml(stepCount)}</span>` : ""}
1560
1609
  </div>
1561
1610
 
1562
1611
  <div class="rc-tokens" ${showTokens ? "" : "style=\"display:none\""}>
@@ -1565,7 +1614,7 @@ function activeCardHtml(run) {
1565
1614
  </div>
1566
1615
 
1567
1616
  <div class="rc-foot">
1568
- <span class="rc-runid">id ${run.runId.slice(0, 8)}</span>
1617
+ <span class="rc-runid">id ${escapeHtml(String(run.runId).slice(0, 8))}</span>
1569
1618
  <span class="sep">·</span>
1570
1619
  <span>${escapeHtml(safeStr(run.tool) || "")}</span>
1571
1620
  </div>
@@ -1708,8 +1757,11 @@ function rowHtml(evt, idx, prev) {
1708
1757
  msg = `<span class="ident">${escapeHtml(badge.toLowerCase())}</span>`;
1709
1758
  }
1710
1759
  const sourcePill = renderSourcePill(evt.payload?.source);
1760
+ // SEC-14-01: evt.type and evt.runId cross publisher boundary → escape.
1761
+ // msg/tokenChip/sourcePill are pre-built HTML literals where each interpolation
1762
+ // already used escapeHtml() — safe by construction.
1711
1763
  return `
1712
- <div class="tl-row" data-type="${evt.type}" data-ok="${ok}" data-grouped="${grouped}">
1764
+ <div class="tl-row" data-type="${escapeHtml(evt.type)}" data-ok="${ok}" data-grouped="${grouped}">
1713
1765
  <div class="tl-time" title="${time}">${rel}</div>
1714
1766
  <div class="tl-rail"><div class="tl-node"></div></div>
1715
1767
  <div class="tl-content">
@@ -1717,7 +1769,7 @@ function rowHtml(evt, idx, prev) {
1717
1769
  <span class="tl-msg">${msg}</span>
1718
1770
  ${tokenChip}
1719
1771
  ${sourcePill}
1720
- ${evt.runId ? `<span class="tl-runid">${evt.runId.slice(0,6)}</span>` : ""}
1772
+ ${evt.runId ? `<span class="tl-runid">${escapeHtml(String(evt.runId).slice(0,6))}</span>` : ""}
1721
1773
  </div>
1722
1774
  </div>
1723
1775
  `;
@@ -1917,7 +1969,7 @@ function applyConnState(s) {
1917
1969
 
1918
1970
  async function hydrateFromState() {
1919
1971
  try {
1920
- const res = await fetch("/state", { credentials: "omit" });
1972
+ const res = await authedFetch("/state", { credentials: "omit" });
1921
1973
  if (!res.ok) return;
1922
1974
  const j = await res.json();
1923
1975
  if (j.port && document.querySelector(".brand-sub")) {
@@ -1938,7 +1990,7 @@ function connectRealSource() {
1938
1990
  applyConnState("connecting");
1939
1991
  if (evtSource) try { evtSource.close(); } catch (_) {}
1940
1992
 
1941
- evtSource = new EventSource("/events");
1993
+ evtSource = new EventSource(authedEventSourceUrl("/events"));
1942
1994
 
1943
1995
  evtSource.addEventListener("open", () => {
1944
1996
  lastConnectedAt = Date.now();