[object Object]

← back to Butlr

security(P0): kill ?reveal=1 leak + owner-auth + robots noindex

5ef12b57fa82610293707ff24d45e8ee140bc461 · 2026-05-12 16:04:14 -0700 · Steve Abrams

Fixes the GPTBot exposure at 22:51:46 UTC where /calls/DTCI7PuZ?reveal=1
returned the decrypted Wells Fargo account number (0209203652) in plain
HTML to anyone with the call ID — no auth, no robots.txt, no noindex.

Changes:
1. lib/owner-auth.js — new middleware. Gates /calls, /calls/:id,
   /listen/:id, /api/calls/:id/* behind a BUTLR_OWNER_TOKEN cookie
   (or ?owner_token=). Constant-time compare. /login route renders a
   paste-token form. /logout clears.
2. routes/public.js — reveal=1 now requires req.ownerAuthed=true.
   Always sets X-Robots-Tag: noindex.
3. server.js — mounts /login + /logout publicly, then requireOwner
   on every call-data surface BEFORE the route handlers see the
   request. Soft-auth everywhere else for view locals.
4. public/robots.txt — Disallow: /calls /listen /api /twilio /login.
   Explicit Disallow: / for GPTBot, ChatGPT-User, OAI-SearchBot,
   ClaudeBot, anthropic-ai, Claude-Web, PerplexityBot, Perplexity-User,
   CCBot, Bytespider, Amazonbot, cohere-ai, Google-Extended,
   FacebookBot, Applebot-Extended, Diffbot, ImagesiftBot, TimpiBot.
5. views/partials/head.ejs — noindex,nofollow,noarchive,nosnippet
   meta on every page by default; routes opt in to indexing.

Verified post-deploy:
  - GET /calls/DTCI7PuZ?reveal=1 (no cookie) → 302 /login (was 200 + acct#)
  - GET /api/calls/DTCI7PuZ/status (no cookie) → 302 /login (was 200 JSON)
  - GET /listen/DTCI7PuZ (no cookie) → 302 /login (was 200 page)
  - GET /robots.txt → 200 (was 404)
  - GET /calls/DTCI7PuZ?reveal=1 + owner cookie → 200 + acct visible (owner OK)
  - GET / (no cookie) → 200 (homepage still public)

BUTLR_OWNER_TOKEN registered with /secrets (digest ...da8c:693bb5f9).
Token persisted at /tmp/butlr_owner_token + Kamatera .env.

Files touched

Diff

commit 5ef12b57fa82610293707ff24d45e8ee140bc461
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 16:04:14 2026 -0700

    security(P0): kill ?reveal=1 leak + owner-auth + robots noindex
    
    Fixes the GPTBot exposure at 22:51:46 UTC where /calls/DTCI7PuZ?reveal=1
    returned the decrypted Wells Fargo account number (0209203652) in plain
    HTML to anyone with the call ID — no auth, no robots.txt, no noindex.
    
    Changes:
    1. lib/owner-auth.js — new middleware. Gates /calls, /calls/:id,
       /listen/:id, /api/calls/:id/* behind a BUTLR_OWNER_TOKEN cookie
       (or ?owner_token=). Constant-time compare. /login route renders a
       paste-token form. /logout clears.
    2. routes/public.js — reveal=1 now requires req.ownerAuthed=true.
       Always sets X-Robots-Tag: noindex.
    3. server.js — mounts /login + /logout publicly, then requireOwner
       on every call-data surface BEFORE the route handlers see the
       request. Soft-auth everywhere else for view locals.
    4. public/robots.txt — Disallow: /calls /listen /api /twilio /login.
       Explicit Disallow: / for GPTBot, ChatGPT-User, OAI-SearchBot,
       ClaudeBot, anthropic-ai, Claude-Web, PerplexityBot, Perplexity-User,
       CCBot, Bytespider, Amazonbot, cohere-ai, Google-Extended,
       FacebookBot, Applebot-Extended, Diffbot, ImagesiftBot, TimpiBot.
    5. views/partials/head.ejs — noindex,nofollow,noarchive,nosnippet
       meta on every page by default; routes opt in to indexing.
    
    Verified post-deploy:
      - GET /calls/DTCI7PuZ?reveal=1 (no cookie) → 302 /login (was 200 + acct#)
      - GET /api/calls/DTCI7PuZ/status (no cookie) → 302 /login (was 200 JSON)
      - GET /listen/DTCI7PuZ (no cookie) → 302 /login (was 200 page)
      - GET /robots.txt → 200 (was 404)
      - GET /calls/DTCI7PuZ?reveal=1 + owner cookie → 200 + acct visible (owner OK)
      - GET / (no cookie) → 200 (homepage still public)
    
    BUTLR_OWNER_TOKEN registered with /secrets (digest ...da8c:693bb5f9).
    Token persisted at /tmp/butlr_owner_token + Kamatera .env.
---
 lib/owner-auth.js       | 121 ++++++++++++++++++++++++++++++++++++++++++++++++
 public/robots.txt       |  74 +++++++++++++++++++++++++++++
 routes/public.js        |   9 +++-
 server.js               |  15 ++++++
 views/partials/head.ejs |   6 +++
 5 files changed, 223 insertions(+), 2 deletions(-)

diff --git a/lib/owner-auth.js b/lib/owner-auth.js
new file mode 100644
index 0000000..d160397
--- /dev/null
+++ b/lib/owner-auth.js
@@ -0,0 +1,121 @@
+// Owner-auth middleware for Butlr.
+//
+// Until Butlr ships real per-user accounts, the call data belongs to ONE
+// person — Steve. So we gate everything that reveals queued calls behind a
+// single owner token stored in env (BUTLR_OWNER_TOKEN).
+//
+// Two ways to authenticate:
+//   1. Cookie `butlr_owner=<token>` — set once via /login?t=<token>
+//   2. Query `?owner_token=<token>` — useful for direct curl checks
+//
+// If the token matches, req.ownerAuthed = true and downstream routes can
+// honor reveal=1, return owner-only data, etc. If it doesn't match, the
+// middleware short-circuits with a 401 or redirects to /login.
+//
+// Also adds X-Robots-Tag: noindex, nofollow, noarchive to every gated
+// response so even if a crawler somehow gets a token, indexed pages are
+// flagged to not be retained.
+
+const COOKIE_NAME = 'butlr_owner';
+const ONE_YEAR = 60 * 60 * 24 * 365;
+
+function parseCookies(req) {
+  const out = {};
+  const h = req.headers.cookie;
+  if (!h) return out;
+  for (const part of h.split(';')) {
+    const idx = part.indexOf('=');
+    if (idx < 0) continue;
+    out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
+  }
+  return out;
+}
+
+function constantTimeEqual(a, b) {
+  if (typeof a !== 'string' || typeof b !== 'string') return false;
+  if (a.length !== b.length) return false;
+  let mismatch = 0;
+  for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
+  return mismatch === 0;
+}
+
+function getExpectedToken() {
+  return (process.env.BUTLR_OWNER_TOKEN || '').trim();
+}
+
+function authed(req) {
+  const expected = getExpectedToken();
+  if (!expected) return false; // env not set → fail closed
+  const fromCookie = parseCookies(req)[COOKIE_NAME];
+  const fromQuery = req.query && req.query.owner_token;
+  return constantTimeEqual(fromCookie || '', expected) || constantTimeEqual(fromQuery || '', expected);
+}
+
+// Middleware: hard-gate (401/redirect)
+function requireOwner(req, res, next) {
+  if (authed(req)) {
+    req.ownerAuthed = true;
+    res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
+    return next();
+  }
+  // For API endpoints return 401 JSON; for page endpoints redirect to /login
+  if (req.path.startsWith('/api/')) {
+    return res.status(401).json({ ok: false, error: 'unauthorized', login_at: '/login' });
+  }
+  const back = encodeURIComponent(req.originalUrl);
+  return res.redirect(302, `/login?return=${back}`);
+}
+
+// Middleware: soft (just sets req.ownerAuthed, never blocks)
+function softAuth(req, res, next) {
+  req.ownerAuthed = authed(req);
+  if (req.ownerAuthed) res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
+  next();
+}
+
+// /login route — accepts ?t=<token>&return=<url>, sets cookie, redirects.
+// Also accepts POST from a tiny form (no body needed in browser flows).
+function mountLogin(router) {
+  router.get('/login', (req, res) => {
+    const t = req.query.t || '';
+    const ret = req.query.return || '/';
+    const expected = getExpectedToken();
+    if (!expected) {
+      return res.status(500).type('text/plain').send('BUTLR_OWNER_TOKEN env not set on server.');
+    }
+    if (t && constantTimeEqual(t, expected)) {
+      res.cookie(COOKIE_NAME, expected, {
+        httpOnly: true,
+        secure: true,
+        sameSite: 'lax',
+        maxAge: ONE_YEAR * 1000,
+        path: '/',
+      });
+      return res.redirect(302, ret);
+    }
+    res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
+    res.type('text/html').send(`<!doctype html>
+<html><head><title>Butlr · Owner login</title>
+<meta name="robots" content="noindex,nofollow,noarchive">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<style>body{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:24px;color:#101828}
+input{font-size:16px;padding:10px;width:100%;box-sizing:border-box;border:1px solid #ccc;border-radius:6px;margin:8px 0}
+button{font-size:16px;padding:10px 20px;background:#0c2d6b;color:#fff;border:0;border-radius:6px;cursor:pointer}</style>
+</head><body>
+<h1>🎩 Butlr — owner sign-in</h1>
+<p>Paste your owner token to access call records.</p>
+<form method="GET" action="/login">
+  <input type="hidden" name="return" value="${ret.replace(/"/g, '&quot;')}">
+  <input type="password" name="t" placeholder="Owner token" autocomplete="off" autofocus>
+  <button type="submit">Sign in</button>
+</form>
+</body></html>`);
+  });
+
+  router.get('/logout', (req, res) => {
+    res.clearCookie(COOKIE_NAME, { path: '/' });
+    res.redirect(302, '/');
+  });
+}
+
+module.exports = { requireOwner, softAuth, mountLogin, COOKIE_NAME };
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..4a20535
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,74 @@
+User-agent: *
+Disallow: /calls
+Disallow: /calls/
+Disallow: /listen
+Disallow: /listen/
+Disallow: /api/
+Disallow: /twilio/
+Disallow: /new/submit
+Disallow: /login
+
+# Public marketing pages are allowed:
+Allow: /$
+Allow: /how-it-works
+Allow: /pricing
+Allow: /terms
+Allow: /privacy
+Allow: /accessibility
+
+# AI training crawlers — explicit deny on the whole site
+User-agent: GPTBot
+Disallow: /
+
+User-agent: ChatGPT-User
+Disallow: /
+
+User-agent: OAI-SearchBot
+Disallow: /
+
+User-agent: ClaudeBot
+Disallow: /
+
+User-agent: anthropic-ai
+Disallow: /
+
+User-agent: Claude-Web
+Disallow: /
+
+User-agent: PerplexityBot
+Disallow: /
+
+User-agent: Perplexity-User
+Disallow: /
+
+User-agent: CCBot
+Disallow: /
+
+User-agent: Bytespider
+Disallow: /
+
+User-agent: Amazonbot
+Disallow: /
+
+User-agent: cohere-ai
+Disallow: /
+
+User-agent: Google-Extended
+Disallow: /
+
+User-agent: FacebookBot
+Disallow: /
+
+User-agent: Applebot-Extended
+Disallow: /
+
+User-agent: Diffbot
+Disallow: /
+
+User-agent: ImagesiftBot
+Disallow: /
+
+User-agent: TimpiBot
+Disallow: /
+
+Sitemap: https://butlr.agentabrams.com/sitemap.xml
diff --git a/routes/public.js b/routes/public.js
index 1689360..69b64ff 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -234,13 +234,18 @@ router.get('/calls', (req, res) => {
 router.get('/calls/:id', (req, res) => {
   const id = req.params.id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
-  const c = data.getCall(id, req.query.reveal === '1');
+  // SECURITY: reveal=1 is gated by owner-auth middleware (applied in server.js).
+  // If we got here, the request is owner-authenticated, so honor reveal=1.
+  // Unauthenticated requests are blocked upstream and never reach this handler.
+  const wantReveal = req.query.reveal === '1' && req.ownerAuthed === true;
+  const c = data.getCall(id, wantReveal);
   if (!c) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
+  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
   res.render('public/call', {
     title: c.business_name + ' — call detail — Butlr',
     meta_desc: 'Call status and submitted details.',
     call: c,
-    revealed: req.query.reveal === '1',
+    revealed: wantReveal,
   });
 });
 
diff --git a/server.js b/server.js
index 46ced6d..7dd5746 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,7 @@ const listenRoutes = require('./routes/listen');
 const agentLogRoutes = require('./routes/agent-log');
 const twilioWebhooks = require('./routes/twilio-webhooks');
 const { attachListenBridge } = require('./lib/listen-bridge');
+const { requireOwner, softAuth, mountLogin } = require('./lib/owner-auth');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9932', 10);
@@ -74,6 +75,20 @@ app.use((req, res, next) => {
 // Twilio webhooks must NOT pass through morgan body-noise & must accept Twilio's
 // own urlencoded callbacks — mount before publicRoutes so /twilio/* short-circuits.
 app.use('/twilio', twilioWebhooks);
+
+// Owner-auth: /login + /logout (always public). Then hard-gate every route
+// that exposes call data so no future route can leak by accident.
+const ownerRouter = express.Router();
+mountLogin(ownerRouter);
+app.use('/', ownerRouter);
+
+// Hard-gate call-data surfaces — applied BEFORE the route files mount their handlers.
+app.use(['/calls', '/calls/:id', '/listen/:call_id', '/api/calls/:call_id/status',
+         '/api/calls/:call_id/chat', '/api/calls/:call_id/agent-log',
+         '/api/calls/:call_id/transcript', '/api/calls/:call_id/recording'], requireOwner);
+// Soft-auth everywhere else so res.locals.ownerAuthed is available in views.
+app.use(softAuth);
+
 app.use('/', listenRoutes);
 app.use('/', agentLogRoutes);
 app.use('/', publicRoutes);
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index 902eb43..5598449 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -5,6 +5,12 @@
   <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
   <title><%= title %></title>
   <meta name="description" content="<%= typeof meta_desc !== 'undefined' ? meta_desc : '' %>">
+  <% if (typeof noindex === 'undefined' ? true : noindex) { %>
+  <!-- Butlr is owner-only data; default to noindex on every page. Public marketing pages
+       opt-in via `noindex=false` in their route. -->
+  <meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
+  <meta name="googlebot" content="noindex, nofollow, noarchive">
+  <% } %>
   <link rel="preconnect" href="https://fonts.googleapis.com">
   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
   <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;1,400&family=Inter:wght@400;500;600;700&display=swap">

← 4d03f06 AI-gather fixes: hermes3:8b model (0.3s warm vs 6.9s qwen3:1  ·  back to Butlr  ·  listen-bridge: per-call PCM archive + ffmpeg→MP3 finalize on 5150ae3 →