GHSA-9rmh-cqxg-g7fm: 
JavaScript Análise e mitigação de vulnerabilidades

Summary

With TINA4_DEBUG=true and the server bound past loopback (for example TINA4_HOST=0.0.0.0), a machine on the network can open the /__dev_reload WebSocket by sending Host: localhost. The same machine gets 403 from every /__dev HTTP endpoint. Once the socket is open, every reload the developer triggers sends {type, file, mtime} to it, so a remote listener sees the path of each file being edited. The default debug bind is 127.0.0.1, so this needs an explicit non-loopback bind. The remote caller cannot trigger a reload or read file contents, because the HTTP endpoints stay gated.

Cause

The upgrade check for /__dev* applies the Host allow-list (ADR-0082) but not the raw socket-peer check that the /__dev HTTP gate applies: packages/core/src/server.ts, the server.on("upgrade") handler. The HTTP gate also calls devPeerAllowed (packages/core/src/devAdmin.ts), and the socket gate does not.

Reproduce (v3 9559385)

  1. Start a project with TINA4_DEBUG=true TINA4_HOST=0.0.0.0.
  2. From another machine on the LAN, send a WebSocket upgrade for /__dev_reload with Host: localhost. The server answers 101 Switching Protocols. GET /__dev/api/mtime from the same machine answers 403.
  3. Locally, POST /__dev/api/reload with {"file":"src/routes/secret-leak.php","type":"reload"}. The remote socket receives that path.

Fix

Apply the same raw-peer check (loopback, plus the TINA4_DEV_ALLOWED_PEERS opt-in) before accepting the upgrade. This matches the documented behaviour: a peer that the gate refuses is already not given the toolbar, so it never had a reload client. Verified on 9559385:

  • The remote socket gets 403.
  • A loopback socket still gets 101 and still receives the reload.
  • With TINA4_DEV_ALLOWED_PEERS=<lan ip>, the remote socket opens again.
  • The regression test fails on unfixed source.
  • The full suite shows no new failures. The same gap exists in the other three ports, and each has been reported separately.
diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts
index 1d02d5d..ea95ea0 100644
--- a/packages/core/src/server.ts
+++ b/packages/core/src/server.ts
@@ -48,7 +48,7 @@ import { generateNonce, runWithCspNonce, currentCspNonce } from "./csp.js";
 import { createHealthRoutes } from "./health.js";
 import { rateLimiter } from "./rateLimiter.js";
 import { Log } from "./logger.js";
-import { DevAdmin, RequestInspector, WsTracker, devRequestDenial, devHostAllowed, devToolbarAllowed } from "./devAdmin.js";
+import { DevAdmin, RequestInspector, WsTracker, devRequestDenial, devHostAllowed, devPeerAllowed, devToolbarAllowed } from "./devAdmin.js";
 import { CLOSE_GOING_AWAY, devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
 import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
 import { I18n } from "./i18n.js";
@@ -2320,8 +2320,9 @@ export async function startServer(config?: Tina4Config): Promise<{
     const upPath = (req.url ?? "/").split("?")[0];
     // Dev-reload channel (debug only).
     if (isDevMode() && upPath === "/__dev_reload") {
-      // ADR-0082: the reload socket answers only a loopback / TINA4_HOST Host.
-      if (!devHostAllowed(req.headers.host)) {
+      // ADR-0082: the reload socket answers only a loopback / TINA4_HOST Host,
+      // from a raw socket peer the /__dev HTTP gate would also let in.
+      if (!devHostAllowed(req.headers.host) || !devPeerAllowed(socket.remoteAddress)) {
         try { socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); socket.destroy(); } catch { /* gone */ }
         return;
       }
diff --git a/test/devSurfaceContract.test.ts b/test/devSurfaceContract.test.ts
index 984c0b8..40c948f 100644
--- a/test/devSurfaceContract.test.ts
+++ b/test/devSurfaceContract.test.ts
@@ -79,9 +79,9 @@ const get = (port: number, path: string, headers: Record<string, string> = {}) =
 const post = (port: number, path: string, body: unknown, headers: Record<string, string> = {}) => request("POST", port, path, body, headers);
 
 /** A raw socket request, so a path like `/../x` reaches the server verbatim. */
-function rawRequest(port: number, head: string): Promise<string> {
+function rawRequest(port: number, head: string, peer = "127.0.0.1"): Promise<string> {
   return new Promise((resolve, reject) => {
-    const socket = net.connect(port, "127.0.0.1", () => socket.write(head));
+    const socket = net.connect(port, peer, () => socket.write(head));
     let raw = "";
     socket.setTimeout(3000, () => socket.destroy());
     socket.on("data", (c) => {
@@ -92,9 +92,9 @@ function rawRequest(port: number, head: string): Promise<string> {
     socket.on("error", reject);
   });
 }
-const wsUpgrade = (port: number, host: string) => rawRequest(port,
+const wsUpgrade = (port: number, host: string, peer = "127.0.0.1") => rawRequest(port,
   `GET /__dev_reload HTTP/1.1\r\nHost: ${host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` +
-  `Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n`);
+  `Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n`, peer);
 
 // ── throwaway project + a sibling that shares its name as a prefix ───────────
 mkdirSync(join(TEST_DIR, "src/routes"), { recursive: true });
@@ -374,6 +374,26 @@ const port = (peerServer.address() as any).port;
     `dash=${dashIn.status} page-has-toolbar=${pageIn.raw.includes("tina4-dev-toolbar")}`);
 }
 
+// ── the reload socket checks the raw peer, not only the Host header ─────────
+// Last: a second bound server changes shared state the checks above rely on.
+{
+  // The Host header is the caller's to choose; the raw socket peer is not. A
+  // server bound past loopback, so a real LAN peer can reach the upgrade.
+  const lanPort = await freePort();
+  const lanBoot = await startServer({
+    port: lanPort, host: "0.0.0.0", routesDir: join(TEST_DIR, "src/routes"),
+    modelsDir: join(TEST_DIR, "src/models"), staticDir: join(TEST_DIR, "public"),
+  });
+  const remote = await wsUpgrade(lanPort, `localhost:${lanPort}`, LAN_IP!).catch((e) => String(e));
+  setEnv({ TINA4_DEV_ALLOWED_PEERS: LAN_IP! });
+  const allowed = await wsUpgrade(lanPort, `localhost:${lanPort}`, LAN_IP!).catch((e) => String(e));
+  setEnv({ TINA4_DEV_ALLOWED_PEERS: undefined });
+  lanBoot.close();
+  assert("a remote peer cannot open the reload socket by claiming localhost",
+    remote.startsWith("HTTP/1.1 403") && allowed.startsWith("HTTP/1.1 101"),
+    `remote=${JSON.stringify(remote.split("\r\n")[0])} allowed=${JSON.stringify(allowed.split("\r\n")[0])}`);
+}
+
 // ── cleanup — reap everything we spawned ────────────────────────────────────
 peerServer.close();
 boot.close();

Origem: NVD

Relacionado JavaScript Vulnerabilidades:

CVE ID

Gravidade

Pontuação

Tecnologias

Nome do componente

Exploit CISA KEV

Tem correção

Data de publicação

CVE-2026-111770HIGH7.7
  • JavaScript logoJavaScript
  • @modelcontextprotocol/server
NãoSimNov 05, 2026
CVE-2026-111769MEDIUM6.5
  • JavaScript logoJavaScript
  • claude-agent-acp
NãoSimNov 05, 2026
GHSA-mrvp-qgq4-c66gMEDIUM4.2
  • JavaScript logoJavaScript
  • cve-lite-cli
NãoSimNov 06, 2026
GHSA-h7pf-67p2-535fMEDIUMN/A
  • JavaScript logoJavaScript
  • @miltonadina/devops
NãoSimNov 06, 2026
GHSA-2cp5-h2w9-phhfMEDIUMN/A
  • JavaScript logoJavaScript
  • @miltonadina/devops
NãoSimNov 06, 2026

Avaliação Gratuita de Vulnerabilidades

Avalie a sua postura de segurança na nuvem

Avalie suas práticas de segurança em nuvem em 9 domínios de segurança para avaliar seu nível de risco e identificar lacunas em suas defesas.

Avaliação de solicitação

Recursos adicionais da Wiz

Marque uma demonstração personalizada

Pronto para ver a Wiz em ação?

"A melhor experiência do usuário que eu já vi, fornece visibilidade total para cargas de trabalho na nuvem."
David EstlickCISO
"A Wiz fornece um único painel de vidro para ver o que está acontecendo em nossos ambientes de nuvem."
Adam FletcherDiretor de Segurança
"Sabemos que se a Wiz identifica algo como crítico, na verdade é."
Greg PoniatowskiChefe de Gerenciamento de Ameaças e Vulnerabilidades