Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/RELEASE_NOTICE.md
Original file line number Diff line number Diff line change
@@ -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 <name>`). 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).
30 changes: 30 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<<RELEASE_NOTICE_EOF"
cat .github/RELEASE_NOTICE.md
echo ""
echo "---"
echo "RELEASE_NOTICE_EOF"
} >> "$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 }}

---
Expand Down
102 changes: 102 additions & 0 deletions .github/workflows/retire-release-notice.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
chore: retire the release notice carried by ${RELEASE_TAG}

.github/RELEASE_NOTICE.md is prepended verbatim to the release body and is
written for one release. ${RELEASE_TAG} has shipped, so it is removed here
to stop it repeating on the next release.

Opened automatically by .github/workflows/retire-release-notice.yml.
EOF
sed -i 's/^ //' /tmp/retire-commit.txt

git commit -q -F /tmp/retire-commit.txt
git push -q origin "$BRANCH"

cat > /tmp/retire-body.md <<EOF
\`.github/RELEASE_NOTICE.md\` is prepended verbatim to the release body by \`release.yml\`, and is written for a single release.

**${RELEASE_TAG} has shipped**, so the notice is removed here. Without this, the same advisory would repeat on every subsequent release — and a reader who sees the same advisory three times stops believing any of them.

If you want the notice to stay for a follow-up patch release, close this PR; another will be opened after the next published release.

Opened automatically by \`.github/workflows/retire-release-notice.yml\`.
EOF
sed -i 's/^ //' /tmp/retire-body.md

gh pr create \
--base main \
--head "$BRANCH" \
--title "chore: retire the release notice carried by ${RELEASE_TAG}" \
--body-file /tmp/retire-body.md
Loading