Isolate internal bot-config route from the public listener (#33)

The GET /internal/bot-config route returns the DECRYPTED Discord bot
token and was mounted on the same Express app / port 3000 that Pangolin
proxies publicly. Its only guard was the BOT_INTERNAL_KEY shared secret,
and .env.example shipped a placeholder default — so a forwarded path or a
weak/unrotated key would expose the plaintext token to the internet.

Move server<->bot internal traffic onto its own listener and fail fast on
a weak key:

- Add server/src/internalApp.js: a standalone Express app mounting
  requireInternalKey + /internal (and a no-secret /health), mirroring the
  bot's unpublished port-4100 pattern.
- server.js starts a second listener on INTERNAL_PORT (default 3001),
  closed on graceful shutdown.
- Remove the /internal mount from the public v1.router; the public app now
  404s /api/v1/internal/bot-config even with a valid key.
- Fail fast: new utils/botInternalKey.js rejects an empty, placeholder, or
  <16-char BOT_INTERNAL_KEY — fatal in production (exit 1), warning in dev.
- docker-compose: bot SITE_INTERNAL_URL -> app:3001/internal/bot-config;
  document that INTERNAL_PORT stays unpublished.
- .env.example (root/server/bot): document INTERNAL_PORT, the fail-fast
  behavior, and a defense-in-depth Pangolin deny rule for /api/v1/internal.

Tests: add requireInternalKey.test.js and botInternalKey.test.js
(node --test: 93 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
2026-07-04 17:35:07 -05:00
parent 7a21cc636c
commit 5df943095d
11 changed files with 261 additions and 15 deletions

26
server/src/internalApp.js Normal file
View File

@@ -0,0 +1,26 @@
// Standalone Express app for server<->bot internal traffic. It is mounted on its
// OWN http listener (INTERNAL_PORT, default 3001) in server.js — an unpublished,
// compose-network-only port, mirroring how the bot exposes its internal API on
// 4100. Crucially it is NOT part of the public API app (app.js), so /internal/*
// (which returns the DECRYPTED Discord bot token) can never ride the same
// listener Pangolin proxies to the world. Shared-secret gated by
// requireInternalKey inside internal.routes. See issue #33.
const express = require('express')
const internalRouter = require('./router/v1/internal/internal.routes')
const internalApp = express()
internalApp.use(express.json())
// Liveness probe for this listener (no secret required); mirrors the bot's
// /health on 4100. Useful for compose healthchecks without exposing anything.
internalApp.get('/health', (req, res) => res.json({ status: 'ok' }))
// requireInternalKey is applied inside internal.routes.
internalApp.use('/internal', internalRouter)
// Anything else on this listener is not a real internal route.
internalApp.use((req, res) => res.status(404).json({ message: 'Not found' }))
module.exports = internalApp

View File

@@ -6,7 +6,8 @@ const ctrl = require('./internal.controller')
const router = express.Router()
// Shared-secret gated, not session-gated — the caller is the bot process, not
// a logged-in browser. Mounted before any auth/session middleware in v1.router.
// a logged-in browser. This router is mounted on the standalone internalApp
// (its own unpublished port), never on the public /api app. See internalApp.js.
router.use(requireInternalKey)
router.get(

View File

@@ -5,13 +5,13 @@ const v1Router = express.Router()
const authRouter = require('./auth/auth.routes')
const publicRouter = require('./public/public.routes')
const adminRouter = require('./admin/admin.routes')
const internalRouter = require('./internal/internal.routes')
v1Router.use('/auth', authRouter)
v1Router.use('/public', publicRouter)
v1Router.use('/admin', adminRouter)
// Shared-secret gated (not session-gated) — server<->bot only, never exposed
// through the public reverse proxy. See server/src/middleware/requireInternalKey.js.
v1Router.use('/internal', internalRouter)
// NOTE: /internal is intentionally NOT mounted here. Those routes return the
// decrypted Discord bot token and must never share the public listener that
// Pangolin proxies. They live on a separate, unpublished port via
// server/src/internalApp.js (started in server.js). See issue #33.
module.exports = v1Router

View File

@@ -2,16 +2,23 @@ require('dotenv').config()
const http = require('http')
const app = require('./app')
const internalApp = require('./internalApp')
const botScore = require('./middleware/botScore')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
const mailer = require('./utils/mailer')
const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const pkg = require('../package.json')
const log = createLogger('server')
const PORT = Number(process.env.PORT) || 3000
// Separate, UNPUBLISHED listener for server<->bot /internal/* traffic. Kept off
// the public PORT so the decrypted-token route can't ride the listener Pangolin
// proxies to the world (issue #33). Must match the port in the bot's
// SITE_INTERNAL_URL (docker-compose.yml).
const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
async function start() {
@@ -25,6 +32,20 @@ async function start() {
smtp: mailer.isConfigured() ? 'configured' : 'not configured (mailto fallback)',
})
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in
// production (the /internal/bot-config route hands back the decrypted Discord
// token and this key is its only guard); a warning otherwise.
const keyCheck = evaluateBotInternalKey({
key: process.env.BOT_INTERNAL_KEY,
nodeEnv: process.env.NODE_ENV,
})
if (keyCheck.fatal) {
log.error(keyCheck.message)
process.exit(1)
} else if (!keyCheck.ok) {
log.warn(keyCheck.message)
}
log.info('ensuring database schema...')
await ensureSchema()
log.info('seeding defaults...')
@@ -39,10 +60,18 @@ async function start() {
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
})
setupShutdown(server)
// Internal server<->bot API on a separate, unpublished port. NEVER expose this
// through Pangolin/the public reverse proxy — it serves the decrypted Discord
// bot token to the bot process over the private compose network only (#33).
const internalServer = http.createServer(internalApp)
internalServer.listen(INTERNAL_PORT, HOST, () => {
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
})
setupShutdown(server, internalServer)
}
function setupShutdown(server) {
function setupShutdown(server, internalServer) {
let closing = false
const shutdown = async (signal) => {
if (closing) return
@@ -50,6 +79,7 @@ function setupShutdown(server) {
log.warn(`${signal} received — shutting down gracefully`)
botScore.stopSweeper() // stop the bot-store cleanup interval
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {
await close()
log.info('database pool closed')

View File

@@ -0,0 +1,43 @@
// Boot-time validation of BOT_INTERNAL_KEY — the shared secret that is the ONLY
// auth on the server<->bot /internal/* routes (which return the decrypted Discord
// bot token). A missing, placeholder, or trivially short key would leave that
// endpoint effectively unguarded, so in production we refuse to start; in dev we
// warn but continue so local work isn't blocked. See issue #33.
// The placeholders shipped in the repo's .env.example files. If any of these
// reaches production it means the operator never generated a real key.
const PLACEHOLDERS = new Set([
'change-me-to-a-long-random-string', // root .env.example
'dev-only-change-me-bot-key', // server/.env.example, bot/.env.example
])
const MIN_LENGTH = 16
// Returns { ok, fatal, message }. `fatal` is only ever true in production —
// callers should exit non-zero on fatal, and log a warning (but continue) when
// !ok && !fatal.
function evaluateBotInternalKey({ key, nodeEnv } = {}) {
const value = key || ''
let reason = null
if (value.length === 0) reason = 'BOT_INTERNAL_KEY is not set'
else if (PLACEHOLDERS.has(value)) reason = 'BOT_INTERNAL_KEY is still the documented placeholder value'
else if (value.length < MIN_LENGTH) reason = `BOT_INTERNAL_KEY is too short (< ${MIN_LENGTH} chars)`
if (!reason) return { ok: true, fatal: false, message: null }
const production = nodeEnv === 'production'
const detail =
`${reason}. It is the only guard on /internal/bot-config, which returns the ` +
'decrypted Discord bot token.'
return {
ok: false,
fatal: production,
message: production
? `${detail} Refusing to start in production — set a long random BOT_INTERNAL_KEY (matching bot/.env).`
: `${detail} Continuing because NODE_ENV is not "production" — set a strong value before deploying.`,
}
}
module.exports = { evaluateBotInternalKey, PLACEHOLDERS, MIN_LENGTH }