From 256fa6c492b4d3670d7ed3242e13d1e765f0f29b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 2 Sep 2026 14:59:37 +0300 Subject: [PATCH 1/2] docs(security): add the release advisory for the credential-exposure fixes The release body is assembled from LLM-generated notes plus a static download table. That is fine for a feature summary and wrong for anything a user has to act on, so a security advisory cannot depend on a summarizer choosing to include it. Adds .github/RELEASE_NOTICE.md, prepended verbatim to the release body when present, and a no-op when absent. The notice covers #1180 (any agent token could read the admin API key, so allowed_servers was not a boundary), #1167 (upstream credentials served to unprivileged callers), #1166 (REST enumeration ignored token scope, including per-server logs and tool-call payloads), #1158 (credentials in main.log - split by the log level each class required, since the OAuth authorization code needed no non-default configuration), and the server-edition defects #1168/#1169. It states the remediation plainly: rotate the API key, re-mint agent tokens, rotate upstream credentials that appeared in logs, and treat existing log files as secret-bearing. Delete the notice file after this release ships or it repeats on the next one. --- .github/RELEASE_NOTICE.md | 48 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 30 ++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 .github/RELEASE_NOTICE.md diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md new file mode 100644 index 000000000..c2cdb0cda --- /dev/null +++ b/.github/RELEASE_NOTICE.md @@ -0,0 +1,48 @@ +## ⚠️ Security release — please read before upgrading + +This release fixes credential-exposure and access-control defects that affect **all previously released versions** of MCPProxy. Two of them mean shipped versions were weaker than the documentation described. If you have used agent tokens, or connected an OAuth upstream, please read the remediation steps below. + +### What was wrong + +**Any agent token could obtain the admin API key** ([#1180](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1180)) +`GET /api/v1/info` returned the global admin API key in its `web_ui_url` field to any authenticated caller, including a read-only token scoped to a single server. The holder could then act as an administrator. + +The practical consequence: **`allowed_servers` was not a security boundary.** A scoped token never had to defeat the scope checks — it could read the admin key and stop being scoped. Treat any agent token you have issued as having had administrator-equivalent access. + +**Upstream credentials were served to unprivileged callers** ([#1167](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1167)) +With the opt-in `reveal_secret_headers` setting enabled, the MCP door correctly required an authenticated administrator, but the REST, SSE and diagnostics doors checked the flag alone. A read-only scoped token received upstream `Authorization` headers, URL query tokens, command-line arguments and environment values in plaintext from `GET /api/v1/servers`, `GET /api/v1/config` and the `/events` stream. + +**Token scope was ignored on REST enumeration** ([#1166](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1166)) +The MCP surface filtered by `allowed_servers`; the REST surface did not. Beyond server names, this exposed `GET /api/v1/servers/{id}/logs` — which serves an upstream server's own stdout/stderr, and therefore anything that server prints — and the activity and tool-call endpoints, which carry tool-call **arguments and responses** for every configured server. + +**Credentials were written to the log** ([#1158](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1158)) +By severity of exposure, and by the log level required: + +| Level | What was logged | +|---|---| +| **`info` (the default)** | OAuth **authorization codes**, the full OAuth callback query string, and OAuth authorization URLs — which embed the configured upstream URL, including any credential in its query string. Also printed to stdout. | +| `debug` | Upstream command-line credentials (`--api-key …`), upstream URL query tokens, and — under Docker isolation, where `env` is converted to `-e KEY=VALUE` arguments — upstream **environment** secrets. | +| `trace` | `Authorization` and `Cookie` headers, request and response bodies (including OAuth token responses), and raw SSE frames. | + +**No non-default configuration was required for the `info` row.** Anyone who has connected an OAuth upstream has authorization codes in `main.log` and in terminal scrollback. Authorization codes are single-use and short-lived, so the durable risk is the authorization-URL line: if an upstream URL carries a static token, that token is in the log of every OAuth-enabled install. + +### Server edition + +- **Admin demotion did not take effect** ([#1169](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1169)) — removing an address from `admin_emails` left the user with full administrator access, including secret reveal, until their session token expired (default 24 hours). Disabling the account did work; demotion alone did not. +- **Agent-token names were a global namespace** ([#1168](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1168)) — two tenants could not both use the same token name, and the differing responses let any user probe for other users' token names. Agent tokens also carried no tenant identity. +- **A disabled user's agent tokens kept working.** Disabling an account is the documented remediation for a compromised user, and it did not revoke that user's minted tokens. +- **Requested `allowed_servers` was not checked against entitlement**, so a tenant could mint a token scoped to servers they were not entitled to see. + +### What you should do + +1. **Rotate your MCPProxy API key.** Remove `api_key` from `~/.mcpproxy/mcp_config.json` and restart to have a new one generated, or set a new value directly. +2. **Revoke and re-mint every agent token** (`mcpproxy token list`, then `mcpproxy token revoke `). Any token issued before this release should be assumed to have had administrator-equivalent reach. +3. **Rotate upstream credentials that appeared in logs.** In order of likelihood: any credential embedded in an upstream server URL; any credential passed as a command-line argument; and, if you ran with Docker isolation at `debug`, upstream environment secrets. +4. **Treat existing log files as secret-bearing.** `~/.mcpproxy/logs/*.log` on Linux, `~/Library/Logs/mcpproxy/*.log` on macOS, `%LOCALAPPDATA%\mcpproxy\logs\*.log` on Windows. Delete them or restrict access; note they may also exist in backups. +5. **Server edition only:** re-check `admin_emails` against who should actually hold admin, and revoke tokens belonging to any disabled account. + +Redaction preserves diagnostics — flag names, hosts, paths and parameter names still appear in logs, so debugging a connection is unaffected. Only the credential values are masked. + +### Reporting + +Security issues can be reported privately through [GitHub Security Advisories](https://github.com/smart-mcp-proxy/mcpproxy-go/security/advisories/new). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4814fad57..d837b2d58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1428,11 +1428,41 @@ jobs: echo "Pending notarizations:" ls -la pending-notarizations/ || echo "No pending notarizations" + # A hand-written notice that must reach the release body verbatim. + # + # The notes above this line are generated by an LLM from the commit log, + # which is fine for a feature summary and wrong for anything a user has + # to ACT on — a security advisory, a migration step, a breaking change. + # Those cannot depend on a summarizer choosing to include them, so they + # are authored in .github/RELEASE_NOTICE.md and prepended here. + # + # Delete .github/RELEASE_NOTICE.md once the release that needs it has + # shipped, or it will repeat on the next one. The step is a no-op when + # the file is absent. + - name: Load hand-written release notice + id: notice + run: | + if [ -f .github/RELEASE_NOTICE.md ]; then + echo "Found .github/RELEASE_NOTICE.md - prepending to the release body" + { + echo "notice<> "$GITHUB_OUTPUT" + else + echo "No .github/RELEASE_NOTICE.md - nothing to prepend" + echo "notice=" >> "$GITHUB_OUTPUT" + fi + - name: Create release with binaries uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: release-files/* body: | + ${{ steps.notice.outputs.notice }} + ${{ needs.generate-notes.outputs.notes }} --- From bbce6be466159bb97d800986a7fa5286c1a9d11b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 2 Sep 2026 15:03:08 +0300 Subject: [PATCH 2/2] ci: retire the release notice automatically once its release ships The notice is prepended verbatim to the release body and is written for one release, so leaving it in place repeats it on the next one - and a reader who sees the same advisory on three releases stops believing any of them. That made deletion a required manual step, which is not a step. This fires on 'release: published' and opens a PR removing the file, so the deletion goes through the same review and checks as any other change and a maintainer who wants to keep the notice for a follow-up patch release can just close the PR. It is a no-op when no notice is present, and it will not stack a second PR on top of an unmerged one. --- .github/workflows/retire-release-notice.yml | 102 ++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/retire-release-notice.yml diff --git a/.github/workflows/retire-release-notice.yml b/.github/workflows/retire-release-notice.yml new file mode 100644 index 000000000..4015aaeb9 --- /dev/null +++ b/.github/workflows/retire-release-notice.yml @@ -0,0 +1,102 @@ +name: Retire release notice + +# .github/RELEASE_NOTICE.md is prepended verbatim to the release body by +# release.yml. It is written for ONE release — a security advisory, a migration +# step, a breaking change — so leaving it in place would silently repeat it on +# every subsequent release, which is worse than not having it: a reader who +# sees the same advisory on three releases stops believing any of them. +# +# Deleting it is therefore a required step, and a required step that depends on +# someone remembering is not a step. This opens the removal PR automatically as +# soon as the release that carried the notice is published. +# +# It opens a PR rather than pushing to main so the deletion goes through the +# same review and checks as any other change, and so a maintainer who wants to +# keep the notice for a follow-up patch release can simply close the PR. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + reason: + description: "Why you are retiring the notice manually" + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + retire: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Check for a notice to retire + id: check + run: | + if [ -f .github/RELEASE_NOTICE.md ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + echo "A release notice is present; it will be retired." + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "No .github/RELEASE_NOTICE.md - nothing to retire." + fi + + - name: Open the removal PR + if: steps.check.outputs.present == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name || 'a manual dispatch' }} + run: | + set -euo pipefail + + # Do not stack a second removal PR on top of an unmerged one. + EXISTING=$(gh pr list --state open --search 'in:title "Retire the release notice"' --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "PR #${EXISTING} is already open to retire the notice; nothing to do." + exit 0 + fi + + BRANCH="chore/retire-release-notice-${GITHUB_RUN_ID}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git rm -q .github/RELEASE_NOTICE.md + + cat > /tmp/retire-commit.txt < /tmp/retire-body.md <