[object Object]

← back to Ticket System

Loopback-gate /api/run + /api/dtd: block Claude-spawning endpoints over the public tunnel (Cody Hole 2); viewing stays public, exec stays local. Reversible via TK_ALLOW_TUNNEL_EXEC=1

4f405809f99cf12d5c9075e47609d27a3fa60acf · 2026-08-25 12:23:10 -0700 · Steve

Files touched

Diff

commit 4f405809f99cf12d5c9075e47609d27a3fa60acf
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 12:23:10 2026 -0700

    Loopback-gate /api/run + /api/dtd: block Claude-spawning endpoints over the public tunnel (Cody Hole 2); viewing stays public, exec stays local. Reversible via TK_ALLOW_TUNNEL_EXEC=1
---
 server.js | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/server.js b/server.js
index 1ccd053b..a6c5ae9a 100644
--- a/server.js
+++ b/server.js
@@ -93,6 +93,21 @@ function noteAuth(req, ok) {
   authFails.set(ip, rec);
   if (authFails.size > 5000) for (const [k, v] of authFails) if (!v.until || now > v.until) authFails.delete(k); // prune
 }
+// Claude-spawning endpoints (/api/run, /api/dtd) must run from a LOCAL operator only.
+// Over the public `tickets` tunnel these would let a remote actor (past the shared cred)
+// spawn Claude sessions on this Mac — Cody Hole 2. Viewing stays public; execution stays
+// local. Reversible: delete this guard's calls to re-open remote exec (do that behind CF
+// Access, not the bare tunnel). Set TK_ALLOW_TUNNEL_EXEC=1 to override (mirrors the
+// dw-pitch-followup ALLOW_TUNNEL_SEND escape hatch).
+const ALLOW_TUNNEL_EXEC = (process.env.TK_ALLOW_TUNNEL_EXEC || '') === '1';
+function execBlockedForRemote(req, res) {
+  if (ALLOW_TUNNEL_EXEC) return false;
+  const remote = req.socket && req.socket.remoteAddress;
+  const proxied = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'];
+  if (LOOPBACK.has(remote) && !proxied) return false;    // local operator — allowed
+  json(res, 403, { error: 'execution endpoints are local-operator only over the public tunnel; run locally or enable behind CF Access' });
+  return true;
+}
 
 const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
 
@@ -273,6 +288,7 @@ http.createServer((req, res) => {
 
   // Run Now — open one iTerm2 Claude session per selected ticket (staggered so iTerm doesn't drop windows).
   if (req.method === 'POST' && req.url === '/api/run') {
+    if (execBlockedForRemote(req, res)) return;
     return readJson(req, body => {
       if (!body) return json(res, 400, { error: 'bad json' });
       const map = tickets(); const ids = resolveList(body.ids, map);
@@ -321,6 +337,7 @@ http.createServer((req, res) => {
   }
   // DTD — trigger a batched run-now sweep (POST) / read the latest verdicts + running state (GET).
   if (req.method === 'POST' && req.url === '/api/dtd') {
+    if (execBlockedForRemote(req, res)) return;
     return readJson(req, body => {
       if (fs.existsSync(DTD_RUNNING)) return json(res, 200, { started: false, already: true });
       const args = [DTD_RUN];

← 64775fd1 Add brute-force lockout to public ticket board (interim hard  ·  back to Ticket System  ·  harden lockout prune: keep active in-window fail-records, pr e7768c09 →