feat(server): require a bearer token on the http transport - #105
Merged
Conversation
Over stdio the client owns the process, so the principal is whoever launched it. Over http the port was the whole access control, and under server-held key custody that means anyone who reaches the port is served with the deployment's Bugzilla credential. http is now deny-by-default. Two environment-only tokens define two scopes: BUGWARDEN_HTTP_TOKEN reaches every tool the policy serves, BUGWARDEN_HTTP_READ_TOKEN the complement of WRITE_TOOLS — the same set read-only mode removes from the router (I13), so the per-caller split cannot drift from the per-deployment one. Neither value enters Cli, so I12 holds structurally rather than through a redacting Debug impl. The gate is an axum layer wrapping the whole router, ahead of rmcp: an unauthenticated caller reaches no MCP handling, and an unrouted path is refused identically to /mcp — one empty 401 with WWW-Authenticate: Bearer, byte-identical however the credential was wrong (I2). Duplicate Authorization headers are refused the same way. A read-scope caller is served a filtered listing and the router's own unknown-tool error for a write tool, so a hidden tool is indistinguishable from a nonexistent one — including under a failing audit sink, where the refusal gate now checks routing first (this also stops a failing sink from answering differently for policy-removed discovery tools). Resolved first of all in main, ahead of the policy load, the Bugzilla client, the identity preflight and the audit sink, so a misconfiguration binds no port and neither creates nor rotates an audit file. The contract is wire-compatible with ruoqa-mcp's bearer scheme for fleet alignment (#32); the implementation is bugwarden's own. Partial delivery of #32: this authenticates a deployment, not a caller, so ClientInfo.principal stays None and #32 stays open until a verified caller identity fills it. BREAKING CHANGE: the http transport, which is the default, refuses to start without BUGWARDEN_HTTP_TOKEN or BUGWARDEN_HTTP_READ_TOKEN. A bare `bugwarden --bugzilla-server ...` that worked before now exits 1. Pass --insecure-no-auth to keep the old behaviour on a trusted network; it has no environment variable, because I9 lets flags tighten only. Refs: #32
This was referenced Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ruoqa-mcp-compatible scoped bearer authentication for the HTTP transport (#32, fleet alignment). BREAKING: http (the default transport) now refuses to start without
BUGWARDEN_HTTP_TOKEN(write scope) orBUGWARDEN_HTTP_READ_TOKEN(read scope);--insecure-no-authis the explicit escape hatch (CLI-only — I9).Contract: env-only tokens (argv is ps-readable), ≥32 printable non-space ASCII, read≠write, deny-by-default resolved before any port binds or audit file exists, constant-time comparison with both tokens always compared, one byte-identical empty 401 +
WWW-Authenticate: Bearerfor every refusal cause including unrouted paths and duplicate Authorization headers (I2). Read scope reuses I13's WRITE_TOOLS set: filteredtools/list, and a refused write call answers the router's own "tool not found" with zero upstream requests — a hidden tool is indistinguishable from a nonexistent one, now also under a failing audit sink (the refusal gate checks routing first; this closed a pre-existing sibling leak for policy-removed discovery tools). stdio ignores tokens.principalstaysNone— a deployment credential is not a caller; #32 remains open for (caller, RRID).Adversarial review before opening
Security lens: 29 bypass probes all byte-identical 401s; no session-scope escalation; I12 clean under RUST_LOG=trace. Its two proven findings — an oracle under failing-sink+closed_all and the
main.rsscope wiring being unpinned (deleting it left 463 tests green) — are fixed, each with a killing test. Docs lens: falsified man-page preamble, unqualified allowed-hosts rationale (in three places), unrunnable examples, stale MCP client config — all corrected; the gate's closing ofserver/discoveris now a recorded DESIGN.md decision.The auth core was rewritten independently after review flagged verbatim overlap with ruoqa-mcp's GPL source: 0 identical doc-comment lines, 0 identical function bodies remain (mechanical difflib check); the compatible part is the wire contract, not the code.
Verification
Rebased on current main. All five AGENTS.md commands independently re-run: fmt/clippy×2 clean, 471 passed / 0 failed, cargo deny fully green. 24/24 mutations killed. Assets regenerated byte-stable.