Repository navigation
Make a bare plugin name resolve to a verified release - #839
Merged
Merged
Conversation
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
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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.
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'smainbranch, 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:
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:
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:
--insecure. Installing an unsignedspec from a mutable branch should be an explicit choice.
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.
--insecurestill reaches the default branch, and now says so. The "unverified source"warning fired only when
owner != "spore-host", so the official unverified path — the onethat mattered — told the user nothing.
A trap I walked into
softFailreturns nil under--insecure. My first version didreturn nil, nil, r.softFail(err), which under--insecurereturns a nil spec with noerror. The refusal branches now
continueinstead, and the tag is built only when aversion was actually resolved — otherwise
officialTag("demo", "")yields the tagdemo-and fetches a ref that cannot exist.A test that certified the vulnerability
TestResolveOfficialBare_NoManifestFetchasserted the bug was correct behaviour: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, withthe 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:
pinning, content hashing). They now use a named
newInsecureTestResolverinstead ofinheriting the old permissive default, so it is visible that they test the
--insecurepath.
/main/.... It now pins thetagged 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:
TestResolveOfficialBare_ResolvesToLatestReleaseAndVerifies— reports the main-branch URL and that the release endpoint was never contactedTestLatestOfficialVersionReportsAPIFailuresAsErrorsTestLatestOfficialVersionOrdersSemantically—latest = "1.9.0", want "1.10.0"TestParseOfficialTagIsolatesThePluginName—demo-extra-1.2.3read as ademoversionTestLatestOfficialVersionSkipsDraftsAndPrereleasesTestLatestOfficialVersionFollowsPaginationThe ordering one is worth dwelling on: a string compare puts
1.9.0above1.10.0, so alexical 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 tailscalemust not resolve to atailscale-exit-noderelease, which would verify the wrong artifact against the wrongmanifest and still pass.
Pagination is asserted with a genuinely full first page whose newest release is on page
two, and bounded at
releaseTagsPageLimitso one install cannot become an unbounded walk.Split out: #838
Advisory remediation item 3 — the signature covers
plugin.yamlbut not the artifacts thespec downloads and runs — is not in this PR.
Step.SHA256is 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-pluginsis 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 0e2e 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.