Repository navigation
fix(sdk,adapters,plugins,edge): a push token with a body_sha256 claim can't carry another bundle - #1138
Merged
alexmercerpo merged 7 commits intoNov 5, 2026
Conversation
…er bundle A push token wasn't bound to the body it came with: within its five minutes, before its jti was used, a captured token could push any other bundle the SaaS signed for the server. The saas source now checks a body_sha256 claim (the base64url SHA-256 of the exact request body) against the bytes the server received, before the replay guard records the jti. A mismatch answers the generic 401 invalid_token and is logged as body_mismatch. A token without the claim is still accepted, with one warning, unless requirePushBodyDigest is on (saas source, managed edge). The HTTP adapters keep the bytes a body was parsed from as ServerRequest.bodyBytes (Express parsers, web-fetch handler), so the push flow hashes the exact body rather than a re-serialization.
…to fix/skilled-push-token-body-binding # Conflicts: # apps/e2e/demo-e2e-cloudflare/e2e/managed-edge-autoupdate.e2e.spec.ts # apps/e2e/demo-e2e-skilled-openapi/e2e/saas-source.e2e.spec.ts
…to fix/skilled-push-token-body-binding
Contributor
|
Warning Review limit reachedYour organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Next included review available in 28 minutes. View limit details
|
frontegg-david
marked this pull request as ready for review
November 5, 2026 11:25
…to fix/skilled-push-token-body-binding
alexmercerpo
approved these changes
Nov 5, 2026
Contributor
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-05T17:45:59.728Z |
…to fix/skilled-push-token-body-binding
…to fix/skilled-push-token-body-binding
…to fix/skilled-push-token-body-binding
Contributor
frontegg-david
added a commit
that referenced
this pull request
Nov 5, 2026
* Cherry-pick: fix(adapters,plugins,edge): an older saas bundle, pulled or pushed, doesn't roll the server back Cherry-picked from #1119 (merged to release/1.11.x) Original commit: d88f422 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk,plugins): an @app's Skilled OpenAPI credential resolver reaches execute_action and this.callTool() of an operation Cherry-picked from #1120 (merged to release/1.11.x) Original commit: 00a3b00 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(adapters,plugins,edge): a push token replayed to another replica or Worker isolate is refused Cherry-picked from #1122 (merged to release/1.11.x) Original commit: 1341026 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(plugins): a skill a skills:filter hook hides can't be run by naming its actions Cherry-picked from #1123 (merged to release/1.11.x) Original commit: a081fbf Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk): a plugin listed twice in one plugins list is refused at startup instead of keeping only its last entry Cherry-picked from #1127 (merged to release/1.11.x) Original commit: 3dbadd3 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(plugins,edge): a Cron run that doesn't apply its bundle fails, and /readyz says the bundle served is stale Cherry-picked from #1128 (merged to release/1.11.x) Original commit: 4938d21 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk): connect() with an authToken connects an anonymous caller again on a server without auth Cherry-picked from #1129 (merged to release/1.11.x) Original commit: 3d238da Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(auth,adapters,plugins,edge): operators can tell why a pull or push token was refused, while callers still get the generic answer Cherry-picked from #1130 (merged to release/1.11.x) Original commit: 913f4f5 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk,auth,plugins): this.authProviders works: the SDK wires the AuthProviders vault from a new authProvidersVault option Cherry-picked from #1131 (merged to release/1.11.x) Original commit: e14c30c Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk,auth): /oauth/connect connects a verified caller who has no vault yet, and asks an anonymous one to sign in Cherry-picked from #1132 (merged to release/1.11.x) Original commit: f46a87a Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk,plugins): the SDK skill surfaces say a plugin's skills haven't loaded yet, retryable, and carry bundleVersion Cherry-picked from #1133 (merged to release/1.11.x) Original commit: 0d1dc94 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk,auth,cli,plugins): the credentials-required error keeps its connect link out of the message, and asks through URL elicitation where the client supports it Cherry-picked from #1134 (merged to release/1.11.x) Original commit: 6d8ea24 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk): a health check registered after startup runs in /readyz, and registerHealthProbe returns a remover Cherry-picked from #1135 (merged to release/1.11.x) Original commit: 7f2f48f Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk,plugins): a bundle swap sends one list_changed per list it changed, not one per entry Cherry-picked from #1136 (merged to release/1.11.x) Original commit: 0296291 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(adapters,plugins,edge): a restarted server serves its cached bundle in place of an older or refused pull, and doesn't write it back Cherry-picked from #1137 (merged to release/1.11.x) Original commit: 358d114 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk,adapters,plugins,edge): a push token with a body_sha256 claim can't carry another bundle Cherry-picked from #1138 (merged to release/1.11.x) Original commit: b870325 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(plugins,edge): managed mode takes confirm, and the audit log says whether the user confirmed a call Cherry-picked from #1139 (merged to release/1.11.x) Original commit: 588289f Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(cli): the generated CLI says unknown command, prints an MCP error prefix once, and offers load_skill only the run lines it has Cherry-picked from #1140 (merged to release/1.11.x) Original commit: 4cfe9a2 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(plugins): load_skill's outputSchema describes a loaded skill once Cherry-picked from #1141 (merged to release/1.11.x) Original commit: 470010e Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> * Cherry-pick: fix(sdk,cli): skills/load's default answer sends formattedContent again, as 1.11.0 did; format: 'markdown' is the opt-in for the smaller one Cherry-picked from #1142 (merged to release/1.11.x) Original commit: a680fe7 Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> --------- Co-authored-by: agentfront[bot] <agentfront[bot]@users.noreply.github.com> Co-authored-by: frontegg-david <69419539+frontegg-david@users.noreply.github.com>
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.
Stacks on #1137.
What and why
A push token wasn't bound to the body it came with. Within its five minutes, before its
jtiwas used, a captured token (from a proxy log, or a middlebox that ends TLS) could carry a different body toPOST /__skilled_openapi/push. The bundle's own signature check refuses a forged bundle, but not another bundle the SaaS signed for this server: one for another channel, an unwanted newer version, or one meant for another tenant when the signing keys are shared.A push token can now carry a
body_sha256claim, and the server checks it:401 invalid_token(the push token was refused). Only the log names the reason:[saas-source] push refused (body_mismatch): its "body_sha256" claim is not the SHA-256 of the pushed body.body_mismatchis a new value oftokenFailure(BundleTokenFailure), in the closed set fix(auth,adapters,plugins,edge): operators can tell why a pull or push token was refused, while callers still get the generic answer #1130 added.jti. A token refused for its body leaves itsjtiunused, so a captured token raced ahead with another body doesn't stop the push it was issued for.The claim name. Nothing in the push protocol defined a body digest: the push spec and helpers, the deploy manifest schema and the catalog docs have none. I picked
body_sha256overbundle_sha256. The bundle already has a digest,integrity.digest, a hex SHA-256 of its canonical form withoutintegrity. The claim is a different hash, of the bytes on the wire, and its name says so.@frontmcp/adapters/skillsexportsPUSH_BODY_DIGEST_CLAIMandpushBodyDigest(body)for senders and tests.A token without the claim is accepted by default. This is a security hardening in a patch release, and SaaS senders that don't set the claim yet would otherwise have every push refused. So:
body_sha256is accepted, and the source logs one warning (once per source, so once per process or isolate) that names the option:a push token carries no "body_sha256" claim, so it isn't bound to its body; set requirePushBodyDigest to refuse such tokens;requirePushBodyDigest: trueon thesaassource, or onmanagedin@frontmcp/edge, refuses such a token with the same401, logged asmissing_claim.A later minor release could turn the option on by default, once the SaaS side sets the claim.
The flow gets the body's bytes. Neither HTTP adapter kept them: the Express host parsed JSON and dropped the buffer, and the web-fetch handler read the body as text.
ServerRequesthas a new field,bodyBytes?: Uint8Array, which both adapters fill as they translate the request, and the push flow passes it to the check:express.json()andexpress.urlencoded()keep theirs through body-parser'sverifyhook. Only the reference is kept: the bytes were already in memory.request.text()did.rawBody:@hono/node-server, which the MCP SDK's Node transport uses, reads arawBodyBuffer on the request as the body, and this change should not alter how MCP requests reach the transport.verifyset, body-parser decodes a non-UTF-8 JSON or form body with its own copy of iconv-lite, not raw-body's. Same version, same work. The test that checks a UTF-8 JSON body loads no iconv-lite encodings now checks both copies.The check stays in the
skilled-openapi:bundle-pushflow'sverifyPushTokenstage, so it is hookable like the rest.Scenarios covered: a token bound to the body pushed with it, a token bound to another body, the same bundle with other whitespace, a non-string claim, a token without the claim (accepted with one warning), and a token without the claim with
requirePushBodyDigest(refused), on Node (Express) and on a Worker (web-fetch).User-facing change
body_sha256, the base64url SHA-256 of the exact request body. With it, the token is good only for that body; any other answers401 invalid_token, and the log sayspush refused (body_mismatch).requirePushBodyDigeston thesaassource and onmanagedin@frontmcp/edge: refuse a push token withoutbody_sha256. Defaultfalse.body_sha256logs one warning per source.tokenFailurevalue:body_mismatch.@frontmcp/adapters/skills:PUSH_BODY_DIGEST_CLAIM('body_sha256') andpushBodyDigest(body).BundlePushReceiver.verifyPushToken(token, body)takes the body's bytes as a second argument.@frontmcp/sdk:ServerRequest.bodyBytes, the bytes a JSON or form body (on Express) or any body (on the web-fetch handler) was parsed from. Custom routes and@HttpRoutehandlers get it too.401.body_sha256: accepted → still accepted, with one warning; refused withrequirePushBodyDigest: true.req.body→ readsreq.bodyBytes./reference/plugins/skilled-openapi(pushed bundles, the push token's claims,requirePushBodyDigest,tokenFailure), the@frontmcp/edgemanaged mode page (managed.requirePushBodyDigest, the push request), and the HTTP routes reference (req.bodyBytes).frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md(the push request,body_sha256,requirePushBodyDigest,body_mismatch)frontmcp-config/references/configure-http.md(req.bodyByteson both adapters)frontmcp-development/references/create-http-route.md(this.request.bodyBytes)The plugin's README (a new section, "A push token bound to its body", with a sender example) and the edge's README are updated too.
Tests
Added:
@frontmcp/adapters,saas-pull.source.push.spec.ts(7 new tests; every existing call now passes a body):body_mismatch, with the message the log shows, and itsjtithen still works with the body it was issued for;body_mismatch;requirePushBodyDigest;requirePushBodyDigest, a token without the claim is refused asmissing_claim, and itsjtistays unused in a shared replay store; a bound token is accepted;pushBodyDigestis unpadded base64url SHA-256 (the empty string's known value), the same from a string and from its UTF-8 bytes.bundle-push.flow.spec.ts: the token check gets the exact bytes of the body, or an empty body when the request had none; a token refused asbody_mismatchanswers the same generic 401 and the log names it.bundle-push.integration.spec.ts(a real server, through the web-fetch handler): a token issued for body A, pushed with body B, gets the generic 401 and nothing is applied; the same token with body A then applies it. The same bundle with other whitespace is refused. A server withrequirePushBodyDigestrefuses a token without the claim and applies a bound one.pushToken(forBody)binds a token to a body.managed.spec.ts:requirePushBodyDigestis forwarded to thesaassource, and left out when unset.express.host.adapter.utf8-json.spec.ts: a UTF-8 JSON body (with a byte order mark), a UTF-16 JSON body and a form body each reach the route withbodyBytesequal to the bytes sent. The iconv-lite checks look at both copies.fetch-handler.http-routes.spec.ts: a POST route oncreateFetchHandler()gets the bytes sent (a byte order mark, extra spaces, a newline), not a re-serialization, next to the parsed body.demo-e2e-cloudflare(workerd): a push whose token was issued for another bundle gets401 invalid_token, the served skills don't change, and the Worker logspush refused (body_mismatch); the same token with its own body then applies, and KV keeps it. The helpers gainedissuer.pushTokenFor(body), andworker.pushsends a string body as is.demo-e2e-skilled-openapi(Node, Express): a push whose token was issued for another bundle gets the generic 401, the log namesbody_mismatchwithout the token, and the server keeps serving 1.0.0. The test that applies a pushed version now pushes with a bound token.Run (after merging #1137):
yarn install --immutable, thennpx nx run-many -t build -p sdk,adapters,plugin-skilled-openapi,edge,testing: passed.npx nx test adapters: 63 suites, 1135 tests, passed.npx nx test plugin-skilled-openapi: 53 suites, 828 tests, passed.npx nx test edge: 10 suites, 110 tests, passed.npx nx test skills: 3 suites, 249 tests, passed.src/server/adapters,src/transport,fetch-handler.http-routes.spec.ts): 88 suites, 1227 tests, passed.npx nx test demo-e2e-skilled-openapi: 11 suites, 98 tests, passed.npx nx test demo-e2e-cloudflare: 3 suites, 33 tests, passed.create()+ one tool is 1,296,264 bytes, at its budget).npx nx run-many -t typecheck -p adapters,plugin-skilled-openapi,edge,sdk,demo-e2e-skilled-openapi, andnpx tsc --noEmit -p <lib>/tsconfig.lib.jsonfor sdk, adapters, plugin-skilled-openapi and edge: no errors.