Skip to content

fix(sdk,adapters,plugins,edge): a push token with a body_sha256 claim can't carry another bundle - #1138

Merged
alexmercerpo merged 7 commits into
fix/skilled-cold-start-cachefrom
fix/skilled-push-token-body-binding
Nov 5, 2026
Merged

alexmercerpo merged 7 commits into
fix/skilled-cold-start-cachefrom
fix/skilled-push-token-body-binding

Conversation

@frontegg-david

Copy link
Copy Markdown
Contributor

Stacks on #1137.

What and why

A push token wasn't bound to the body it came with. Within its five minutes, before its jti was used, a captured token (from a proxy log, or a middlebox that ends TLS) could carry a different body to POST /__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_sha256 claim, and the server checks it:

  • The claim is the base64url SHA-256, without padding, of the exact bytes of the request body.
  • The server hashes the bytes it received, not a re-serialization of the parsed JSON. Any other body, even the same bundle with other whitespace, answers the generic 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_mismatch is a new value of tokenFailure (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.
  • The body is checked before the replay guard records the jti. A token refused for its body leaves its jti unused, 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_sha256 over bundle_sha256. The bundle already has a digest, integrity.digest, a hex SHA-256 of its canonical form without integrity. The claim is a different hash, of the bytes on the wire, and its name says so. @frontmcp/adapters/skills exports PUSH_BODY_DIGEST_CLAIM and pushBodyDigest(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:

  • by default, a token without body_sha256 is 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: true on the saas source, or on managed in @frontmcp/edge, refuses such a token with the same 401, logged as missing_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. ServerRequest has a new field, bodyBytes?: Uint8Array, which both adapters fill as they translate the request, and the push flow passes it to the check:

  • Express: the UTF-8 JSON parser keeps the buffer it read, and express.json() and express.urlencoded() keep theirs through body-parser's verify hook. Only the reference is kept: the bytes were already in memory.
  • The web-fetch handler (Workers, Deno, Bun): it reads the body as bytes, keeps them, and decodes them as request.text() did.
  • The field isn't named rawBody: @hono/node-server, which the MCP SDK's Node transport uses, reads a rawBody Buffer on the request as the body, and this change should not alter how MCP requests reach the transport.
  • With verify set, 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-push flow's verifyPushToken stage, 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

  • What changed:
    • A push token may carry body_sha256, the base64url SHA-256 of the exact request body. With it, the token is good only for that body; any other answers 401 invalid_token, and the log says push refused (body_mismatch).
    • New option requirePushBodyDigest on the saas source and on managed in @frontmcp/edge: refuse a push token without body_sha256. Default false.
    • A push token without body_sha256 logs one warning per source.
    • New tokenFailure value: body_mismatch.
    • New exports in @frontmcp/adapters/skills: PUSH_BODY_DIGEST_CLAIM ('body_sha256') and pushBodyDigest(body).
    • BundlePushReceiver.verifyPushToken(token, body) takes the body's bytes as a second argument.
    • New in @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 @HttpRoute handlers get it too.
  • Before → after:
    • A captured push token sent with another signed bundle, within its five minutes: applied → refused with 401.
    • A push token without body_sha256: accepted → still accepted, with one warning; refused with requirePushBodyDigest: true.
    • A route handler that needed the exact body (for a signature): had to re-serialize req.body → reads req.bodyBytes.
  • frontmcp.dev pages affected (if known): /reference/plugins/skilled-openapi (pushed bundles, the push token's claims, requirePushBodyDigest, tokenFailure), the @frontmcp/edge managed mode page (managed.requirePushBodyDigest, the push request), and the HTTP routes reference (req.bodyBytes).
  • Skills catalog entries updated:
    • frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md (the push request, body_sha256, requirePushBodyDigest, body_mismatch)
    • frontmcp-config/references/configure-http.md (req.bodyBytes on 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):
    • a token bound to the body pushed with it is accepted, with no warning;
    • a token bound to another body is refused as body_mismatch, with the message the log shows, and its jti then still works with the body it was issued for;
    • the same body with other whitespace is refused: the digest covers the exact bytes;
    • a claim that isn't a string is refused as body_mismatch;
    • a token without the claim is accepted, and two such pushes log one warning, which names requirePushBodyDigest;
    • with requirePushBodyDigest, a token without the claim is refused as missing_claim, and its jti stays unused in a shared replay store; a bound token is accepted;
    • pushBodyDigest is unpadded base64url SHA-256 (the empty string's known value), the same from a string and from its UTF-8 bytes.
  • Plugin:
    • 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 as body_mismatch answers 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 with requirePushBodyDigest refuses a token without the claim and applies a bound one.
    • The fixture's pushToken(forBody) binds a token to a body.
  • Edge, managed.spec.ts: requirePushBodyDigest is forwarded to the saas source, and left out when unset.
  • SDK:
    • 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 with bodyBytes equal to the bytes sent. The iconv-lite checks look at both copies.
    • fetch-handler.http-routes.spec.ts: a POST route on createFetchHandler() 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 gets 401 invalid_token, the served skills don't change, and the Worker logs push refused (body_mismatch); the same token with its own body then applies, and KV keeps it. The helpers gained issuer.pushTokenFor(body), and worker.push sends 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 names body_mismatch without 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, then npx 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.
  • SDK, the suites this touches (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.
  • The bundle-size e2e: 12 suites, 63 tests, passed. The sizes are unchanged (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, and npx tsc --noEmit -p <lib>/tsconfig.lib.json for sdk, adapters, plugin-skilled-openapi and edge: no errors.
  • Prettier, ESLint and the unused-import cleanup on the changed files: clean.

…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
@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Next included review available in 28 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between aaf3098 and 9665de5.

Files selected for processing: 27

  • apps/e2e/demo-e2e-cloudflare/e2e/helpers/managed-bundles.ts
  • apps/e2e/demo-e2e-cloudflare/e2e/helpers/managed-worker.ts
  • apps/e2e/demo-e2e-cloudflare/e2e/managed-edge-autoupdate.e2e.spec.ts
  • apps/e2e/demo-e2e-skilled-openapi/e2e/saas-source.e2e.spec.ts
  • libs/adapters/src/skills/__tests__/saas-pull.source.push.spec.ts
  • libs/adapters/src/skills/index.ts
  • libs/adapters/src/skills/source-options.ts
  • libs/adapters/src/skills/sources/index.ts
  • libs/adapters/src/skills/sources/saas-pull.source.ts
  • libs/adapters/src/skills/sources/skill-bundle-source.interface.ts
  • libs/edge/README.md
  • libs/edge/src/__tests__/managed.spec.ts
  • libs/edge/src/managed.ts
  • libs/sdk/src/common/interfaces/server.interface.ts
  • libs/sdk/src/front-mcp/__tests__/fetch-handler.http-routes.spec.ts
  • libs/sdk/src/server/adapters/__tests__/express.host.adapter.utf8-json.spec.ts
  • libs/sdk/src/server/adapters/express.host.adapter.ts
  • libs/sdk/src/server/adapters/utf8-json.parser.ts
  • libs/sdk/src/transport/web-fetch-handler.ts
  • libs/skills/catalog/frontmcp-config/references/configure-http.md
  • libs/skills/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md
  • libs/skills/catalog/frontmcp-development/references/create-http-route.md
  • plugins/plugin-skilled-openapi/README.md
  • plugins/plugin-skilled-openapi/src/__tests__/bundle-push.flow.spec.ts
  • plugins/plugin-skilled-openapi/src/__tests__/bundle-push.integration.spec.ts
  • plugins/plugin-skilled-openapi/src/__tests__/saas-bundles.fixture.ts
  • plugins/plugin-skilled-openapi/src/push/bundle-push.flow.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@frontegg-david
frontegg-david marked this pull request as ready for review November 5, 2026 11:25
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Performance Test Results

Status: ✅ All tests passed

Summary

Project Tests Passed Warnings Failed Leaks
✅ demo-e2e-agents 4 4 0 0 0
✅ demo-e2e-cache 11 11 0 0 0
✅ demo-e2e-codecall 4 4 0 0 0
✅ demo-e2e-config 4 4 0 0 0
✅ demo-e2e-direct 3 3 0 0 0
✅ demo-e2e-elicitation 1 1 0 0 0
✅ demo-e2e-errors 4 4 0 0 0
✅ demo-e2e-hooks 3 3 0 0 0
✅ demo-e2e-multiapp 4 4 0 0 0
✅ demo-e2e-notifications 3 3 0 0 0
✅ demo-e2e-openapi 2 2 0 0 0
✅ demo-e2e-providers 4 4 0 0 0
✅ demo-e2e-public 4 4 0 0 0
✅ demo-e2e-redis 15 15 0 0 0
✅ demo-e2e-remember 4 4 0 0 0
✅ demo-e2e-remote 5 5 0 0 0
✅ demo-e2e-serverless 2 2 0 0 0
✅ demo-e2e-skills 15 15 0 0 0
✅ demo-e2e-standalone 2 2 0 0 0
✅ demo-e2e-transport-recreation 3 3 0 0 0
✅ demo-e2e-ui 4 4 0 0 0

Total: 101 tests across 21 projects

📊 View full report in workflow run


Generated at: 2026-11-05T17:45:59.728Z
Commit: e60d47a0

@alexmercerpo
alexmercerpo merged commit b870325 into release/1.11.x Nov 5, 2026
46 checks passed
@alexmercerpo
alexmercerpo deleted the fix/skilled-push-token-body-binding branch November 5, 2026 17:54
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Cherry-picked with its stack

This PR is part of stack #1121. Its cherry-pick to main is made as one PR for the whole stack when the stack's top PR, #1142, merges.

If #1142 does not merge, run the Cherry-pick to main workflow by hand with stack: 1121.

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>
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.

2 participants