Skip to content

fix(ruby): MPP replay-store fail-closed + x402 accepted-amount binding (+ #216 hardening)#222

Merged
lgalabru merged 13 commits into
split/pr216-ci-harness-megafrom
fix/ruby-security-hardening
Jul 10, 2026
Merged

fix(ruby): MPP replay-store fail-closed + x402 accepted-amount binding (+ #216 hardening)#222
lgalabru merged 13 commits into
split/pr216-ci-harness-megafrom
fix/ruby-security-hardening

Conversation

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator

Ruby slice of the #216 pre-release security hardening, re-delivered onto #219 (base = split/pr216-ci-harness-mega), plus the residual fixes the bedrock language audit found on top of it. No harness/CI files — those live in #219. First of the per-language / per-protocol PRs that flow into #219; when they all land, #219's SDK conformance/behavior gates go green.

Security behavior

  • MPP replay-store fail-closed off-localnet (config.rb, runtime.rb, rack/payment_required.rb). Localnet keeps the dev MemoryStore with a warning. BREAKING: non-localnet Mpp.create now requires a durable replay_store (raises ConfigurationError) — prevents same-signature replay across workers/restarts.
  • x402 v2 accepted.amount binding (schemes/exact/types.rb): a credential echoing a drifting amount/maxAmountRequired is now rejected, aligned to the Rust verifier (rust/crates/kit/src/x402/server/exact.rs:816); omission is still tolerated for v2 client compatibility.
  • Plus the rest of Pre-release security hardening and CI/harness push (9 SDKs) #216's Ruby hardening.

Verification

mise ruby@3.4.5 bundle exec rake test463 runs, 1377 assertions, 0 failures, 0 errors. New/updated regression tests cover the fail-closed store wiring and the accepted-amount tamper case.

Follow-up

refactor(ruby): idiomatic (Shopify Ruby + rubystyle.guide) will follow as a separate PR (keeping the behavior fix and the style pass reviewable independently), also targeting #219.

The Ruby slice of the #216 pre-release security hardening, plus the residual
fixes the bedrock audit found on top of it, re-delivered onto #219 (harness).
No harness/CI files — those live in #219.

Security behavior:
- MPP replay-store fail-closed off-localnet (config/runtime/rack wiring); localnet
  keeps the dev MemoryStore with a warning. BREAKING: non-localnet Mpp.create now
  requires a durable replay_store.
- x402 v2 accepted.amount binding: a credential that echoes a drifting amount /
  maxAmountRequired is rejected (aligned to the Rust verifier exact.rs:816);
  omission still tolerated for v2 compatibility.
- plus the rest of #216's Ruby hardening.

Verified: mise ruby@3.4.5 bundle exec rake test -> 463 runs, 0 failures, 0 errors.
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR delivers the Ruby slice of the #216 security hardening: MPP replay-store fail-closed enforcement off localnet, x402 v2 accepted.amount binding, and the revised L8 settlement order (broadcast → reserve → confirm) with a double-gated release path for provably-never-landed transactions.

  • MPP fail-closed replay store: Mpp.create now raises ConfigurationError when invoked off-localnet without a durable store; the Rack middleware correctly withholds the nil replay_store from the options hash so the sentinel default still reaches the guard logic.
  • x402 amount binding: accepted_requirement_matches? now checks left_amount.nil? (credential omits → pass, v2 compat) instead of the old !right.key?(key) (server always has amount → dead code), fixing the omission-tolerance regression.
  • Settlement order hardening: The signature reservation (put_if_absent) is now taken immediately after broadcast; a TransactionNotFound from the confirmer only releases the reservation after transaction_blockhash_expired? independently confirms expiry via the server's own RPC, so a maliciously-low echoed lastValidBlockHeight cannot by itself free a replay slot.

Confidence Score: 5/5

The three blocking concerns from the previous review round are all correctly resolved, and no new correctness or security issues were introduced.

The webrick CVE exposure is gone, the nil replay_store passthrough is fixed, accepted_requirement_matches? omission-tolerance now gates on the credential rather than the server requirement, and the lastValidBlockHeight release path is double-gated by a server-side isBlockhashValid call so client-echoed hints cannot independently free a replay reservation. The one new finding is a documentation gap around SettlementCache#delete that only affects custom cache implementations, not the default in-process path.

ruby/lib/pay_kit/protocols/x402/server/exact.rb — custom settlement_cache implementors now need to provide delete in addition to put_if_absent and duplicate?; this requirement is not surfaced in the constructor docs or a base class.

Important Files Changed

Filename Overview
ruby/lib/pay_kit/protocols/x402/server/exact.rb Core settlement logic restructured (broadcast to reserve to confirm); adds delete to SettlementCache, double-gated release via transaction_blockhash_expired?, and lastValidBlockHeight threading. The new delete method is not exposed by any documented base interface, so custom settlement_cache implementations are silently broken on the not-landed path.
ruby/lib/pay_kit/protocols/x402/protocol/schemes/exact/types.rb Fixes accepted_requirement_matches?: omission tolerance now correctly gates on left_amount.nil? (credential omits) rather than !right.key? (server always has amount), restoring v2 client compatibility while binding echoed amounts.
ruby/lib/pay_kit/rack/payment_required.rb Fixes nil replay_store passthrough: the options hash now only includes replay_store: when the caller actually configured one, so the localnet DEV_ONLY_MEMORY_STORE sentinel and non-localnet guard in runtime.rb work as intended.
ruby/lib/pay_kit/protocols/mpp/runtime.rb Adds fail-closed enforcement: raises ConfigurationError when DEV_ONLY_MEMORY_STORE sentinel is used off-localnet, and separately when an explicit nil store is supplied; includes durable_replay_store? duck-type check for non-localnet paths.
ruby/lib/pay_kit/protocols/x402/protocol/schemes/exact/verify.rb Removes the global reject_fee_payer_in_instruction_accounts! sweep and narrows the managed-signer check to the transfer instruction's source, tail, and ATA derivation, aligning with the Rust spine's canonical check and fixing over-rejection of mint/destination references.
ruby/lib/pay_kit/protocols/mpp/protocol/solana/verifier.rb Extracts TransferExpectation, SplTransferExpectation, and AtaCreationPolicy structs to replace positional parameter lists; introduces TOKEN_PROGRAMS and ALLOWED_PAYMENT_PROGRAMS constants to DRY up inline array literals.
ruby/solana-pay-kit.gemspec Moves webrick from runtime to development dependency and tightens the json version constraint to >= 2.19.9, < 3.0.
ruby/lib/pay_kit/protocols/mpp/store.rb Adds FileStore (file-backed, durable) alongside the existing MemoryStore; documents the cross-process limitation and satisfies the durable? duck-type check in runtime.rb.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant Rack as Rack Middleware
    participant Mpp as Mpp.create
    participant X402 as X402 Server::Exact
    participant RPC as Solana RPC

    Client->>Rack: POST /pay (credential)
    Rack->>Mpp: "create(**options) [replay_store forwarded only if non-nil]"
    alt non-localnet and no durable store
        Mpp-->>Rack: ConfigurationError
    end

    Rack->>X402: settle_payment(decoded, requirements)
    X402->>X402: accepted_requirement_matches?(accepted, candidate)
    note over X402: left_amount.nil? pass v2 compat, left_amount != right_amount reject

    X402->>RPC: broadcast signed tx
    RPC-->>X402: signature
    X402->>X402: settlement_cache.put_if_absent(consumed_key)
    note over X402: RESERVE before confirmation poll

    X402->>RPC: getSignatureStatuses poll loop
    alt confirmed
        RPC-->>X402: status present
        X402-->>Client: 200 + X-PAYMENT-RESPONSE
    else timeout TransactionNotFound
        X402->>RPC: isBlockhashValid(tx.message.blockhash)
        alt blockhash expired trusted RPC
            RPC-->>X402: false
            X402->>X402: settlement_cache.delete(consumed_key)
            X402-->>Client: TransactionNotFound
        else blockhash still valid or RPC error
            X402-->>Client: timed out reservation KEPT
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant Rack as Rack Middleware
    participant Mpp as Mpp.create
    participant X402 as X402 Server::Exact
    participant RPC as Solana RPC

    Client->>Rack: POST /pay (credential)
    Rack->>Mpp: "create(**options) [replay_store forwarded only if non-nil]"
    alt non-localnet and no durable store
        Mpp-->>Rack: ConfigurationError
    end

    Rack->>X402: settle_payment(decoded, requirements)
    X402->>X402: accepted_requirement_matches?(accepted, candidate)
    note over X402: left_amount.nil? pass v2 compat, left_amount != right_amount reject

    X402->>RPC: broadcast signed tx
    RPC-->>X402: signature
    X402->>X402: settlement_cache.put_if_absent(consumed_key)
    note over X402: RESERVE before confirmation poll

    X402->>RPC: getSignatureStatuses poll loop
    alt confirmed
        RPC-->>X402: status present
        X402-->>Client: 200 + X-PAYMENT-RESPONSE
    else timeout TransactionNotFound
        X402->>RPC: isBlockhashValid(tx.message.blockhash)
        alt blockhash expired trusted RPC
            RPC-->>X402: false
            X402->>X402: settlement_cache.delete(consumed_key)
            X402-->>Client: TransactionNotFound
        else blockhash still valid or RPC error
            X402-->>Client: timed out reservation KEPT
        end
    end
Loading

Reviews (14): Last reviewed commit: "Merge remote-tracking branch 'origin/spl..." | Re-trigger Greptile

… CVE ignore

Greptile 3/5: the amount tolerance predicate was keyed on `right` (the server's
candidate requirement, which always carries the amount), so a v2 credential that
legitimately OMITS the amount was rejected instead of tolerated. Fix: match
scheme/network/asset/payTo strictly, and bind the amount by value (across
`amount`/`maxAmountRequired`) only when the credential echoes one -- omission is
tolerated (the facilitator fills it in), drift is rejected. Mirrors the Rust
verifier. Adds a regression test for the omission-tolerated case.

Also ignore CVE-2026-38969 (webrick trailer reparse) in bundler-audit: webrick is
a transitive test/tooling dep (no require in the SDK runtime), no patched version
released yet; documented in ruby/.bundler-audit.yml with a removal trigger.

Verified: standardrb clean; COVERAGE runner 464 runs 0 failures (96.8% line /
90.21% branch); bundle-audit reports no vulnerabilities.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Fixed the 3/5 finding: the amount tolerance predicate was reversed (keyed on the server candidate right, which always carries the amount, so it was inert and a v2 credential omitting the amount got rejected). Now scheme/network/asset/payTo match strictly, and the amount is bound by VALUE across amount/maxAmountRequired only when the credential (left) echoes one — omission is tolerated, drift is rejected, mirroring the Rust verifier. Added test_settlement_tolerates_accepted_omitting_amount for the omission case (the drift case was already covered). Also fixed the Ruby CI red: CVE-2026-38969 (webrick, a transitive test dep with no patched release yet) is now a documented ruby/.bundler-audit.yml ignore with a removal trigger. standardrb clean, 464 tests green, bundle-audit clean.

@greptile-apps please review

Comment thread ruby/solana-pay-kit.gemspec Outdated
Greptile: webrick is a declared runtime dependency in solana-pay-kit.gemspec (a
Rack server backend alongside puma), not a test/tooling-only dep as the previous
comment claimed. Corrected the rationale: the SDK never requires webrick directly,
puma is the recommended production server, the CVE has no patched release yet, and
the removal trigger notes dropping webrick from runtime deps as the alternative.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Fixed the 4/5 note: you're right, webrick is a declared runtime dependency in solana-pay-kit.gemspec (a Rack server backend alongside puma), not test/tooling-only. Corrected the .bundler-audit.yml rationale to say exactly that — the SDK never requires webrick directly, puma is the recommended production server, there's no patched webrick yet, and the removal trigger notes dropping webrick from runtime deps as the alternative. The ignore itself is unchanged (bundle-audit checks the full lockfile regardless of dep group).

@greptile-apps please review

Two Greptile findings:
- webrick was declared as a runtime dependency, exposing downstream consumers to
  CVE-2026-38969. Moved it to a dev-only dependency (the SDK never requires it at
  runtime; puma is the production server), so consumers no longer receive it.
  bundler-audit still sees it in the dev lockfile (also via yard), documented ignore
  retained.
- invoke_signature_confirmer only detected :key/:keyreq params, so a confirmer that
  accepts **kwargs (:keyrest) was called without the definitive-expiry
  last_valid_block_height. Now the :keyrest splat is honored. Adds a regression test.

Verified: gemspec validate, standardrb clean, 465 tests green, bundle-audit clean.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Both remaining findings fixed (7d... see commit):

  • webrick runtime-dep / CVE exposure: moved webrick from add_dependency to add_development_dependency in the gemspec — downstream consumers no longer receive it (the SDK never requires webrick at runtime; puma is the production server). It stays in the dev lockfile so the documented .bundler-audit.yml ignore keeps CI green.
  • **invoke_signature_confirmer kwargs edge case: the introspection only matched :key/:keyreq, so a confirmer accepting **kwargs (:keyrest) was called without last_valid_block_height. Now the splat is honored; added test_signature_confirmer_with_kwargs_receives_last_valid_block_height.

gemspec validate + standardrb clean, 465 tests green, bundle-audit clean.

@greptile-apps please review

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please review

Comment on lines +274 to +282
options = {
method: method,
secret_key: secret,
realm: realm,
replay_store: replay_store,
expires_in: expires_in
)
}
options[:replay_store] = replay_store unless replay_store.nil?
::PayKit::Protocols::Mpp.create(**options)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Nil replay_store always passed, bypassing the DEV_ONLY_MEMORY_STORE sentinel

replay_store: replay_store is unconditionally included in the options hash on line 278 even when replay_store is nil (the MppConfig default). The subsequent line 281 is dead code — it re-assigns the same value when not nil, and is a no-op when nil because line 278 already stored it. The result: Mpp.create receives replay_store: nil instead of letting the default parameter kick in, which hits the explicit nil guard in runtime.rb:69 and raises ConfigurationError: "nil is not a valid store" rather than falling through to the intended localnet DEV_ONLY_MEMORY_STORE warning + MemoryStore fallback.

Any localnet user of the Rack middleware who has not set config.mpp.replay_store will get an unconditional ConfigurationError instead of the documented dev-friendly behaviour. The fix is to remove the replay_store: entry from the initial hash so the conditional assignment on line 281 is the sole writer.

Comment on lines +618 to +625
end

# Invoke the injected confirmer while preserving its 2-arg public
# contract. The default confirmer (`await_confirmation`) accepts a
# `last_valid_block_height:` keyword for the definitive expiry check;
# injected confirmers that do not declare it are still called with
# exactly `(config, signature)`.
def invoke_signature_confirmer(config, signature, last_valid_block_height)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Client-echoed lastValidBlockHeight governs a security-critical release

credential_last_valid_block_height reads decoded["accepted"]["extra"]["lastValidBlockHeight"], which is attacker-controlled input. A malicious client can set this to 0 or 1, making block_height > last_valid_block_height trivially true and collapsing the "definitively never landed" proof to only the getSignatureStatuses-absent check. Under network congestion, if the confirmation poll (10 s default) exhausts before the transaction reaches the "processed" commitment level, the absent-status window is exploitable: the reservation is released while the transaction is still live, which can enable a second settlement attempt. The fix is to embed lastValidBlockHeight in the server-side challenge record and read it from there, rather than trusting the echoed credential value.

…through

Resolve the two P1 security findings on this PR:
- exact.rb: the replay-reservation release no longer trusts the client-echoed
  accepted.extra.lastValidBlockHeight. A maliciously-low echoed height could
  force the confirmer to raise TransactionNotFound and release the reservation
  while the transaction was still in-flight (premature release -> replay /
  double-serve). Release now requires the transaction's OWN signed
  recentBlockhash to be independently proven expired on-chain
  (isBlockhashValid == false) via a trusted gate (injectable
  blockhash_validator, real RPC by default); fail-closed on nil/ambiguous/error.
  The echoed height remains only a non-authoritative confirmer hint.
- payment_required.rb: stop inserting an explicit replay_store: nil into the
  Mpp.create options, which defeated the localnet MemoryStore fallback (a dev
  with no store hit ConfigurationError). Only forward a configured store.

Regressions: premature-release blocked while blockhash still valid, genuine
release on proven expiry, validator-error fail-closed, localnet MemoryStore
fallback vs non-localnet fail-closed. rake test 483/0; standardrb clean.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Both P1s addressed:

  • exact.rb (release gate): the replay-reservation release no longer depends on the client-echoed accepted.extra.lastValidBlockHeight. It now requires the transaction's OWN signed recentBlockhash to be independently proven expired on-chain (isBlockhashValid == false) via a trusted, injectable blockhash_validator (real RPC by default), fail-closed on any nil/ambiguous/RPC error. A low echoed height can no longer coerce a premature release.
  • payment_required.rb (nil passthrough): no longer inserts an explicit replay_store: nil into the Mpp.create options, so a localnet dev without a store gets the MemoryStore fallback while non-localnet still fails closed.

Regression tests cover premature-release-blocked, genuine-release-on-expiry, validator-error fail-closed, and the localnet fallback. rake test 483/0, standardrb clean, branch coverage 90.07%. @greptile-apps please review

@lgalabru
lgalabru merged commit 3435e20 into split/pr216-ci-harness-mega Jul 10, 2026
15 checks passed
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