Skip to content

Make a bare plugin name resolve to a verified release - #839

Merged
scttfrdmn merged 2 commits into
mainfrom
fix/816-plugin-signature-default
Oct 11, 2026
Merged

scttfrdmn merged 2 commits into
mainfrom
fix/816-plugin-signature-default

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Closes #816. Advisory: GHSA-54x4-pc23-4m6j (draft; publishes on merge).
Advisory item 3 is split out as #838 — see the end.

The defect

spawn plugin install <name> — no @version — resolved to the registry's main
branch, and performed no signature check, no checksum check, and emitted no warning. A
plugin spec runs as root on the instance (pkg/pluginruntime/remote_executor.go).

The verification itself is well built and genuinely fail-closed. The problem was the
condition deciding whether it runs:

gitRef := pr.Version
if pr.Host == "official" && pr.Version != "" {   // ← bare name: false
    tag := officialTag(pr.Name, pr.Version)
    prov.ReleaseTag = tag
    gitRef = tag
}
...
if prov.ReleaseTag != "" {                        // ← so never entered
    if err := r.verifyRelease(ctx, pr, prov); err != nil { return nil, nil, err }
}

So the install form a person is most likely to type was the one that checked nothing.

The gate was not wrong when it was written. Bare-name resolution used to 404 against
the official registry layout (#448), making the path unreachable. That bug was fixed, and
fixing it quietly armed this one. Confirmed today:

$ curl -o /dev/null -w '%{http_code}' \
    https://raw.githubusercontent.com/spore-host/spore-plugins/main/plugins/tailscale/plugin.yaml
200

Fix

A bare official name resolves to the plugin's latest release tag — exactly the input
the verified path already takes. Verification is unchanged; it simply now runs. The
verified path becomes the default rather than the opt-in.

Refusals, not fallbacks:

  • No releases published → refuse, pointing at --insecure. Installing an unsigned
    spec from a mutable branch should be an explicit choice.
  • Releases API unreadable (403/429/5xx/404, or malformed JSON) → refuse. If a rate
    limit returned "no releases", an attacker could induce the unverified path just by
    exhausting the quota. "The API says there are none" and "we could not ask" are
    different, and only one is safe to act on.

--insecure still reaches the default branch, and now says so. The "unverified source"
warning fired only when owner != "spore-host", so the official unverified path — the one
that mattered — told the user nothing.

A trap I walked into

softFail returns nil under --insecure. My first version did
return nil, nil, r.softFail(err), which under --insecure returns a nil spec with no
error. The refusal branches now continue instead, and the tag is built only when a
version was actually resolved — otherwise officialTag("demo", "") yields the tag
demo- and fetches a ref that cannot exist.

A test that certified the vulnerability

TestResolveOfficialBare_NoManifestFetch asserted the bug was correct behaviour:

A bare official ref (no version) has no release/manifest; resolution must succeed and
must NOT mark ManifestVerified. The release server fails the test if it's ever contacted.

Accurate about the code, false about the design — and because it was written from the
code, it would have failed on the fix and argued against it. It is now
TestResolveOfficialBare_ResolvesToLatestReleaseAndVerifies, asserting the opposite, with
the reason recorded in place. That is CLAUDE.md rule 5, and this is the clearest instance
of it the audit has turned up: the test did not merely miss the gap, it defended it.

Two consequential test updates follow from the behaviour change, both made explicit rather
than quiet:

  • Three provenance tests legitimately exercise the default-branch path (commit-SHA
    pinning, content hashing). They now use a named newInsecureTestResolver instead of
    inheriting the old permissive default, so it is visible that they test the --insecure
    path.
  • The spec-path test's "official bare name" case asserted /main/.... It now pins the
    tagged URL, because fetching at the tag is the mechanism by which verification is
    reached — the path assertion is the thing that proves it.

Gates

On version selection, since that is the input deciding whether verification happens at
all. Six compiling reverts, each failing the intended gate:

revert caught by
bare name falls through to the default branch TestResolveOfficialBare_ResolvesToLatestReleaseAndVerifies — reports the main-branch URL and that the release endpoint was never contacted
API failure treated as "no releases" TestLatestOfficialVersionReportsAPIFailuresAsErrors
lexical instead of semantic ordering TestLatestOfficialVersionOrdersSemantically — latest = "1.9.0", want "1.10.0"
prefix no longer isolated TestParseOfficialTagIsolatesThePluginName — demo-extra-1.2.3 read as a demo version
drafts/prereleases included TestLatestOfficialVersionSkipsDraftsAndPrereleases
pagination not followed TestLatestOfficialVersionFollowsPagination

The ordering one is worth dwelling on: a string compare puts 1.9.0 above 1.10.0, so a
lexical max would install an older plugin than the one published, and the install would
succeed and verify cleanly. Nothing would look wrong.

Prefix isolation matters for the same reason — install tailscale must not resolve to a
tailscale-exit-node release, which would verify the wrong artifact against the wrong
manifest and still pass.

Pagination is asserted with a genuinely full first page whose newest release is on page
two, and bounded at releaseTagsPageLimit so one install cannot become an unbounded walk.

Split out: #838

Advisory remediation item 3 — the signature covers plugin.yaml but not the artifacts the
spec downloads and runs — is not in this PR. Step.SHA256 is optional and unenforced,
but the live official plugins declare no checksums at all (tailscale: 0, spore-sync: 0),
so enforcing it here would break every official install until spore-plugins is updated.
Filed as #838 with the ordering of work.

Verification

make check (all nested modules), make check-docs, changelog gate,
semgrep scan --config=auto --error . (0 findings), make api-snapshot (no drift), Tier 0
e2e with -count=1.

Couples to #448 as the advisory notes: further work on bare-name resolution without this
would have widened the unverified path rather than closed it.

Two independent authorization defects in lambda/dashboard-api.

POST /api/slack/token/rotate was routed above getUserFromRequest
alongside the two OAuth routes — but unlike those, with no comment
saying why — and the API Gateway route is $default with
AuthorizationType: NONE. It took a workspace's current bot token out of
the request body, rotated it with Slack, replaced the stored one, and
returned the NEW token as {"ok":true,"token":"xoxb-..."}. Possession of
a leaked or already-stale token was therefore the only credential needed
to mint a fresh valid one, while the legitimate owner lost theirs.

Confirmed against production with a deliberately invalid token: the
endpoint answered 500 "workspace not found for token", which shows the
handler ran with nothing in front of it. Nothing was written, because
the lookup fails before the update. spore-bot-workspaces holds seven
real workspaces.

Inbound requests are now verified with Slack's HMAC signature over the
raw body, within a five-minute replay window checked as an ABSOLUTE skew
so a replay cannot be refreshed by dating it forward. The signing secret
this needs already existed — slackSigningSecret(), stored in Secrets
Manager and on every workspace row — and had simply never been used to
verify anything. The response no longer carries the token, and the error
no longer says whether a token matched a workspace, which was an oracle
for whether a token is live.

Separately, handleGetWatch read user_id out of the raw DynamoDB item and
skipped the comparison entirely when the attribute was absent, or
present but not a string. It was the only fail-open ownership check in
the file set; sweeps.go and instances.go already compare directly.
WatchInfo now carries UserID, tagged json:"-" because it is an
authorization input rather than dashboard data and returning it would
leak the owning principal's IAM ARN — so an absent owner is a mismatch
instead of a bypass.

Two of the gates assert ORDER rather than presence: verifying after the
credential has been rotated is a check that cannot prevent anything. And
TestNoOwnershipCheckIsConditionalOnTheAttributeExisting discovers the
fail-open shape across the whole package instead of pinning the one line
that was wrong.

The test signing secret is worded rather than a hex blob on purpose:
Slack's own documentation example trips semgrep's generic-secret rule,
and suppressing that would train the next reader to suppress it too.

Refs GHSA-qc2h-6qqf-wcm4
Closes #821
`spawn plugin install <name>` — the shortest and most natural form, with
no @Version — fetched the spec from the registry's main branch. The
verification machinery is well built and genuinely fail-closed, but it
is gated on prov.ReleaseTag being set, and a bare name left it empty. So
the install form a person is most likely to type did no signature check,
no checksum check, and emitted no warning, while a plugin spec runs as
root on the instance.

The gate was not wrong when written: bare-name resolution used to 404
against the official registry layout (#448), so the path was
unreachable. Fixing that quietly armed this. Measured today,
plugins/tailscale/plugin.yaml on main returns HTTP 200.

A bare official name now resolves to the plugin's latest release tag,
which is exactly the input the verified path already takes. Verification
itself is unchanged — it simply now runs. A plugin with no releases, or
a releases API that cannot be read, refuses rather than falling back to
the mutable branch: a rate limit must not be able to select the
unverified path. --insecure still reaches the default branch, and now
says so, since the "unverified source" warning fired only for
third-party owners and told the user nothing about the official one.

softFail returns NIL under --insecure, so the refusal branches continue
rather than return; the tag is therefore built only when a version was
actually resolved. Getting that wrong would have produced the tag
"demo-" and fetched a ref that cannot exist.

TestResolveOfficialBare_NoManifestFetch asserted this vulnerability was
correct behaviour — it required that a bare ref resolve successfully,
not set ManifestVerified, and NEVER contact the release endpoint. That
was an accurate description of the code and a false description of the
design, and being written from the code it would have failed on the fix.
It is inverted now, with the reason recorded in place (rule 5).

Resolving an official ref from the default branch is reachable only
under --insecure, so three provenance tests that legitimately exercise
that path use an explicitly-named newInsecureTestResolver instead of
quietly inheriting the old default, and the spec-path test pins the
TAGGED URL for a bare name.

The new gates target version selection, since that is the input deciding
whether verification happens at all: semantic rather than lexical
ordering (a string compare puts 1.9.0 above 1.10.0 and would silently
install an older plugin), plugin-name isolation so demo-extra-1.2.3 is
not a version of demo, drafts and prereleases excluded, API failures
reported as errors and never as "no releases", and pagination followed
and bounded.

Advisory item 3 — signatures covering only plugin.yaml and not the
artifacts it fetches — is #838 instead of this PR: the live official
plugins declare no checksums at all, so enforcing it here would break
every official install until the registry is updated.

Refs GHSA-54x4-pc23-4m6j
Refs #838
Closes #816
@scttfrdmn scttfrdmn added this to the Security audit 2026-10 milestone Oct 11, 2026
@codecov

codecov Bot commented Oct 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/plugin/latest_release.go 91.07% 3 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@scttfrdmn
scttfrdmn merged commit 790c099 into main Oct 11, 2026
7 checks passed
@scttfrdmn
scttfrdmn deleted the fix/816-plugin-signature-default branch October 11, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden plugin install: require a verified release for official plugins

1 participant