[SECURITY AUDIT] Decrypted Discord bot token served from an endpoint on the public API router, guarded only by a shared secret #33
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: Medium (escalates toward High if the default
BOT_INTERNAL_KEYships or the proxy forwards the path)Confidence: Medium — depends on reverse-proxy (Pangolin) path filtering that is not visible in this repo
Scope area: Token storage / secret handling (audit areas 4 & 5), auth transition (area 2)
What
GET /internal/bot-configreturns the decrypted Discord bot token:This route is mounted inside the same Express app that serves the public API:
api.router.jsmounts/v1→v1.router.js:15mounts/internal→ so the full path is/api/v1/internal/bot-config, on the same listener (port 3000) that Pangolin proxies to the world. The only access control isrequireInternalKey(server/src/middleware/requireInternalKey.js), a timing-safe compare againstBOT_INTERNAL_KEY.The code comments repeatedly assert this route is "never exposed through the public reverse proxy," but nothing in this repository enforces that — there is no separate port, no bind to the compose-internal interface, and no IP allowlist. The guarantee rests entirely on (a) Pangolin being configured to not forward
/api/v1/internal/*, and (b)BOT_INTERNAL_KEYbeing strong..env.exampleships the placeholderBOT_INTERNAL_KEY=change-me-to-a-long-random-string.Why it matters
If either assumption fails — the proxy forwards the path, or the key is left at the documented placeholder / a weak value — an unauthenticated remote attacker retrieves the plaintext Discord bot token and can fully control the bot (read channels, post, manage roles/bans depending on its Discord permissions). The whole point of encrypting the token at rest (
secretBox, write-only admin API) is undermined by a plaintext read endpoint sharing the public attack surface. By contrast, the bot's own internal API is correctly isolated on an unpublished port (4100); the server's internal route is not.Where
server/src/router/v1/internal/internal.controller.js:8-17(returns decrypted token)server/src/router/v1/v1.router.js:15(mounted under the public/api/v1)server/src/middleware/requireInternalKey.js(sole guard).env.example/server/.env.example(BOT_INTERNAL_KEYplaceholder default)Suggested fix (not implemented)
Move server↔bot internal traffic off the public listener: bind the
/internalroutes to a second Express server on a compose-internal-only port (mirroring how the bot exposes 4100 unpublished), or add an allowlist restricting/internal/*to the compose network source range. At minimum, fail startup in production ifBOT_INTERNAL_KEYis unset or equal to the placeholder, and document an explicit proxy deny rule for/api/v1/internal. Defense in depth: even a strong shared secret shouldn't be the only thing between the open internet and a plaintext credential.