[object Object]

← back to Visual Factory

B: pre-fetch user image_url and re-host on loopback (closes DNS rebinding)

30c6d24ae667e602ac1ab949c60eb612d11211a6 · 2026-04-30 23:32:16 -0700 · temp

Files touched

Diff

commit 30c6d24ae667e602ac1ab949c60eb612d11211a6
Author: temp <temp@local>
Date:   Thu Apr 30 23:32:16 2026 -0700

    B: pre-fetch user image_url and re-host on loopback (closes DNS rebinding)
---
 CHANGES2.md |  8 ++++++
 server.js   | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 89 insertions(+), 6 deletions(-)

diff --git a/CHANGES2.md b/CHANGES2.md
new file mode 100644
index 0000000..c4dd778
--- /dev/null
+++ b/CHANGES2.md
@@ -0,0 +1,8 @@
+## Round 2 Applied
+- server.js:5 — imported `fsConstants` from `node:fs` — needed so executable checks can use `X_OK` instead of existence-only access (CODEX_REREVIEW.md “New issues introduced” server.js:225/server.js:231 and “Recommendations”).
+- server.js:224 — updated `assertExecutable()` to reject directories and require execute permission for absolute/relative `CLAUDE_CLI` paths and `PATH` candidates — fixes `/health/deep` false positives where non-executable files could report `claude_cli: ok` while `spawn()` would later fail (CODEX_REREVIEW.md “STATUS concern” and P2 new issues at server.js:225/server.js:231).
+
+## Still Deferred
+- CODEX_REREVIEW.md listed no missed call-sites.
+- No P0/P1 mechanical regressions were listed in CODEX_REREVIEW.md beyond the executable-check concern fixed above.
+- Original deferred security/workflow issues from CHANGES.md remain for Steve because they require policy or broader design decisions outside this round’s safe scope: auth/CSRF for mutating routes, generated HTML sandboxing/CSP/network policy, retry/cancellation semantics, render dimension bounds, artifact path trust, activation validation/atomicity, vision approval policy, crash-stage logging semantics, offline font/network behavior, and the missing package script.
diff --git a/server.js b/server.js
index 4dc595a..22a9648 100644
--- a/server.js
+++ b/server.js
@@ -2,6 +2,7 @@
 
 require('dotenv').config();
 const fs = require('node:fs/promises');
+const { constants: fsConstants } = require('node:fs');
 const path = require('node:path');
 const os = require('node:os');
 const { spawn } = require('node:child_process');
@@ -222,13 +223,18 @@ const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';
 
 async function assertExecutable(command) {
   if (path.isAbsolute(command) || command.includes(path.sep)) {
-    await fs.access(command);
+    const stat = await fs.stat(command);
+    if (stat.isDirectory()) throw new Error(`${command} is a directory`);
+    await fs.access(command, fsConstants.X_OK);
     return;
   }
 
   for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
     try {
-      await fs.access(path.join(dir, command));
+      const candidate = path.join(dir, command);
+      const stat = await fs.stat(candidate);
+      if (stat.isDirectory()) continue;
+      await fs.access(candidate, fsConstants.X_OK);
       return;
     } catch {}
   }
@@ -246,6 +252,50 @@ function buildIgCaption(run, art) {
   return lines.join('\n').trim() || (run.brief || '').slice(0, 280);
 }
 
+// Pre-fetch a user-supplied image_url and re-host it under our own loopback
+// route so Norma never resolves the user's hostname. This closes the
+// DNS-rebinding TOCTOU window that `isAllowedAfterDns` alone leaves open
+// (the validator and Norma's later fetch each do their own DNS resolution).
+//
+// Safety guards layered here:
+//   1. `redirect: 'manual'` — never follow redirects (each would re-open the
+//      rebinding window on a different host).
+//   2. Content-Type allowlist — only image/{png,jpeg,jpg,webp,gif}.
+//   3. Max body size 25 MB; reject anything larger.
+//   4. 15s overall timeout.
+//   5. Caller must already have validated the URL via `isAllowedAfterDns`.
+const _ALLOWED_IMG_CT = /^image\/(png|jpe?g|webp|gif)\b/i;
+const _MAX_PREFETCH_BYTES = 25 * 1024 * 1024;
+async function prefetchAndRehost(url, runId) {
+  const ctrl = new AbortController();
+  const timer = setTimeout(() => ctrl.abort(), 15_000);
+  try {
+    const res = await fetch(url, { signal: ctrl.signal, redirect: 'manual' });
+    clearTimeout(timer);
+    if (res.status >= 300 && res.status < 400) {
+      return { ok: false, error: `redirect to ${res.headers.get('location') || '?'} not allowed (would reopen DNS-rebinding window)` };
+    }
+    if (!res.ok) return { ok: false, error: `http ${res.status}` };
+    const ct = res.headers.get('content-type') || '';
+    const m = ct.match(_ALLOWED_IMG_CT);
+    if (!m) return { ok: false, error: `content-type "${ct}" not allowed; image/{png,jpeg,webp,gif} only` };
+    // Size cap — read as ArrayBuffer once we know CT is OK.
+    const buf = Buffer.from(await res.arrayBuffer());
+    if (buf.length > _MAX_PREFETCH_BYTES) {
+      return { ok: false, error: `${buf.length} bytes exceeds ${_MAX_PREFETCH_BYTES} cap` };
+    }
+    const ext = (m[1] || 'png').toLowerCase().replace('jpeg', 'jpg');
+    const fname = `external-${Date.now()}.${ext}`;
+    const fpath = path.join(__dirname, 'output', `run-${runId}`, fname);
+    await fs.mkdir(path.dirname(fpath), { recursive: true });
+    await fs.writeFile(fpath, buf);
+    return { ok: true, path: fpath, fname, size: buf.length, contentType: ct };
+  } catch (err) {
+    clearTimeout(timer);
+    return { ok: false, error: err.message };
+  }
+}
+
 // Post the activated artifact to Norma's instagram-agent. Best-effort.
 // `imageUrl` should be a URL Norma can reach — for simulation mode any string
 // works; for real Meta-Graph posting it must be publicly fetchable.
@@ -492,6 +542,26 @@ app.get('/runs/:id/events', async (req, res) => {
 
 // Static-serve sandbox PNGs so the viewer can show thumbnails.
 //
+// Serve a pre-fetched external image for a run. Used by the post-ig flow
+// when a user supplies image_url — we download it once into the sandbox,
+// then hand Norma a loopback URL pointing here. Path-traversal defense:
+// fname must match a strict pattern, and the resolved path is asserted
+// to live under the per-run output dir.
+app.get('/runs/:id/external/:fname', async (req, res) => {
+  const runId = Number(req.params.id);
+  const fname = String(req.params.fname || '');
+  if (!Number.isFinite(runId) || !/^external-\d+\.(png|jpg|webp|gif)$/.test(fname)) {
+    return res.status(400).json({ error: 'invalid run id or fname' });
+  }
+  const fpath = path.join(__dirname, 'output', `run-${runId}`, fname);
+  let safe;
+  try { safe = assertWithin(SANDBOX_OUTPUT, fpath); }
+  catch { return res.status(400).json({ error: 'path outside sandbox' }); }
+  res.sendFile(safe, err => {
+    if (err && !res.headersSent) res.status(404).json({ error: 'not found' });
+  });
+});
+
 // Codex P1 #6 fix: assert png_path lives under our output sandbox (or the
 // publish root for activated artifacts) before sendFile, so a poisoned DB row
 // can't make us serve arbitrary files (e.g., /etc/passwd).
@@ -550,15 +620,20 @@ app.post('/runs/:id/post-ig', async (req, res) => {
     // ranges, file://, or AWS metadata. Only public http(s) hosts allowed.
     let imageUrl = `http://127.0.0.1:${PORT}/runs/${runId}/png`;
     if (req.body && typeof req.body.image_url === 'string' && req.body.image_url.length) {
-      // Use the DNS-aware validator: blocks both literal-private hosts AND
-      // hostnames that *resolve* to private/loopback/metadata addresses
-      // (DNS rebinding). The route is already async, so the await is free.
+      // 1. Validator (synchronous + DNS-resolved) gates the URL we're about to fetch.
       if (!(await isAllowedAfterDns(req.body.image_url))) {
         return res.status(400).json({
           error: 'image_url rejected — must be public http(s); private/loopback/metadata addresses blocked (DNS-resolved)',
         });
       }
-      imageUrl = req.body.image_url;
+      // 2. Pre-fetch the image into our sandbox and re-host on a loopback URL
+      //    so Norma never resolves the user's hostname (closes the DNS-rebinding
+      //    TOCTOU window: validate-then-fetch could resolve to different IPs).
+      const fetched = await prefetchAndRehost(req.body.image_url, runId);
+      if (!fetched.ok) {
+        return res.status(400).json({ error: `image_url prefetch failed: ${fetched.error}` });
+      }
+      imageUrl = `http://127.0.0.1:${PORT}/runs/${runId}/external/${fetched.fname}`;
     }
 
     const result = await postToInstagramViaNorma({ run, art, imageUrl });

← 5de52a6 use DNS-aware SSRF gate on /runs/:id/post-ig  ·  back to Visual Factory  ·  harden prefetchAndRehost: pin DNS, stream size, magic bytes, 05edcc6 →