← 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
A lib/owner-auth.jsA public/robots.txtM routes/public.jsM server.jsM views/partials/head.ejs
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, '"')}">
+ <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 →