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

Merged
whitlocktech merged 2 commits from bugfix/internal-token-endpoint-33 into main 2026-07-04 22:50:08 +00:00
Member

Fixes #33.

Problem

GET /internal/bot-config returns the decrypted Discord bot token (server/src/router/v1/internal/internal.controller.js). It was mounted under the public /api app — app.jsapiRouterv1.router.js mounted /internal — so the full path /api/v1/internal/bot-config lived on the same port 3000 listener that Pangolin proxies to the world. The only access control was requireInternalKey, a timing-safe compare against BOT_INTERNAL_KEY, and .env.example shipped the placeholder change-me-to-a-long-random-string.

The code comments asserted the route was "never exposed through the public reverse proxy," but nothing in the repo enforced that. The guarantee rested entirely on (a) Pangolin being configured to not forward /api/v1/internal/*, and (b) the shared secret being strong. If either failed, an unauthenticated remote attacker could retrieve the plaintext token and fully control the bot — defeating the point of encrypting it at rest.

By contrast, the bot's own internal API is correctly isolated on an unpublished port (4100); the server's internal route was not.

Fix

Both remediations from the issue, mirroring the bot's port-4100 pattern:

1. Move /internal onto its own, unpublished listener

  • New server/src/internalApp.js — a standalone Express app mounting requireInternalKey + /internal (and a no-secret /health).
  • server.js starts a second listener on INTERNAL_PORT (default 3001), bound 0.0.0.0 but never published in compose, and closed on graceful shutdown.
  • Removed the /internal mount from the public v1.router.js. The public app now 404s /api/v1/internal/bot-config even with a valid key — the decrypted-token route is gone from the public attack surface.
  • docker-compose.yml: bot SITE_INTERNAL_URLhttp://app:3001/internal/bot-config; documented that INTERNAL_PORT stays unpublished.

2. Fail fast on a weak BOT_INTERNAL_KEY

  • New server/src/utils/botInternalKey.js rejects an empty, documented-placeholder, or <16-char key: fatal in production (process.exit(1) at boot), warning in dev.

3. Docs / defense in depth

  • .env.example (root/server/bot): document INTERNAL_PORT, the fail-fast behavior, and an explicit Pangolin deny rule for /api/v1/internal as belt-and-braces.

Deployment note ⚠️

The bot's SITE_INTERNAL_URL port changed (3000 → 3001). Server and bot must be redeployed together — a partial rollout will 401 the bot's boot-time config fetch until both sides match. Anyone running with a placeholder/short BOT_INTERNAL_KEY in production will now fail to start (intentional) and must set a strong key.

Verification

  • npm test (server): 93 pass / 0 fail, incl. new requireInternalKey.test.js and botInternalKey.test.js.
  • Runtime check: internal listener gates /internal (401 without key) and serves /health; public listener 404s the old path with a valid key header; /api/health still 200.

🤖 Generated with Claude Code

https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV

Fixes #33. ## Problem `GET /internal/bot-config` returns the **decrypted** Discord bot token (`server/src/router/v1/internal/internal.controller.js`). It was mounted under the public `/api` app — `app.js` → `apiRouter` → `v1.router.js` mounted `/internal` — so the full path `/api/v1/internal/bot-config` lived on the **same port 3000 listener that Pangolin proxies to the world**. The only access control was `requireInternalKey`, a timing-safe compare against `BOT_INTERNAL_KEY`, and `.env.example` shipped the placeholder `change-me-to-a-long-random-string`. The code comments asserted the route was "never exposed through the public reverse proxy," but nothing in the repo enforced that. The guarantee rested entirely on (a) Pangolin being configured to not forward `/api/v1/internal/*`, and (b) the shared secret being strong. If either failed, an unauthenticated remote attacker could retrieve the plaintext token and fully control the bot — defeating the point of encrypting it at rest. By contrast, the bot's own internal API is correctly isolated on an unpublished port (4100); the server's internal route was not. ## Fix Both remediations from the issue, mirroring the bot's port-4100 pattern: **1. Move `/internal` onto its own, unpublished listener** - New `server/src/internalApp.js` — a standalone Express app mounting `requireInternalKey` + `/internal` (and a no-secret `/health`). - `server.js` starts a **second listener on `INTERNAL_PORT` (default `3001`)**, bound `0.0.0.0` but **never published** in compose, and closed on graceful shutdown. - Removed the `/internal` mount from the public `v1.router.js`. The public app now **404s `/api/v1/internal/bot-config` even with a valid key** — the decrypted-token route is gone from the public attack surface. - `docker-compose.yml`: bot `SITE_INTERNAL_URL` → `http://app:3001/internal/bot-config`; documented that `INTERNAL_PORT` stays unpublished. **2. Fail fast on a weak `BOT_INTERNAL_KEY`** - New `server/src/utils/botInternalKey.js` rejects an empty, documented-placeholder, or `<16`-char key: **fatal in production** (`process.exit(1)` at boot), **warning** in dev. **3. Docs / defense in depth** - `.env.example` (root/server/bot): document `INTERNAL_PORT`, the fail-fast behavior, and an explicit **Pangolin deny rule for `/api/v1/internal`** as belt-and-braces. ## Deployment note ⚠️ The bot's `SITE_INTERNAL_URL` port changed (3000 → 3001). Server and bot must be redeployed **together** — a partial rollout will 401 the bot's boot-time config fetch until both sides match. Anyone running with a placeholder/short `BOT_INTERNAL_KEY` in production will now **fail to start** (intentional) and must set a strong key. ## Verification - `npm test` (server): **93 pass / 0 fail**, incl. new `requireInternalKey.test.js` and `botInternalKey.test.js`. - Runtime check: internal listener gates `/internal` (401 without key) and serves `/health`; public listener 404s the old path with a valid key header; `/api/health` still 200. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
wtclaude added 1 commit 2026-07-04 22:37:23 +00:00
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
whitlocktech approved these changes 2026-07-04 22:49:28 +00:00
whitlocktech added 1 commit 2026-07-04 22:49:38 +00:00
whitlocktech merged commit 1cfb79f5ae into main 2026-07-04 22:50:08 +00:00
whitlocktech deleted branch bugfix/internal-token-endpoint-33 2026-07-04 22:50:08 +00:00
Sign in to join this conversation.
No description provided.