Skip to content

No minimum bound on subscriptions.interval_seconds lets the keeper drain a payer's on-chain allowance every poll cycle #47

Description

@abayomicornelius

Overview

SubscriptionService::create (src/services/subscription.rs:48-101) only validates that interval_seconds is positive — there is no lower bound at all:

if req.interval_seconds <= 0 {
    return Err(AppError::Validation("interval_seconds must be positive".into()));
}

The database migration matches this — interval_seconds BIGINT NOT NULL CHECK (interval_seconds > 0) (migrations/006_add_subscriptions.sql:24) — so nothing anywhere in the stack stops a client from creating a subscription with interval_seconds = 1.

The keeper background loop (main.rs:134-168) polls for due work every keeper_poll_interval_secs (default 60, configurable, floored at 5 via .max(5) at main.rs:138). SubscriptionService::run_due_executions selects every active subscription whose next_execution_at <= NOW() (subscription.rs:203-214) and, on a successful execution, reschedules next_execution_at = Utc::now() + ChronoDuration::seconds(sub.interval_seconds) (subscription.rs:263-264). With interval_seconds = 1, a subscription becomes "due" again essentially immediately after each execution — meaning it will be picked up and executed on every single keeper pass for as long as it keeps succeeding, i.e. once every keeper_poll_interval_secs (as often as every 5 seconds, if an operator has tuned the poll interval down), indefinitely, with no cap.

Since execute_subscription moves real funds against a pre-granted on-chain allowance (per this file's own extensive doc comments: "the keeper only pays the transaction fee; whether the call actually succeeds ... is entirely gated by the on-chain authorization the payer already granted"), a subscription created (accidentally via a client bug, or deliberately by a malicious/compromised payer account, or by a griefer targeting a recipient they don't like by spamming tiny payments to them) with an unreasonably small interval will have the keeper draining that pre-authorized allowance far faster than any sane recurring-payment product should allow, and hammering the Stellar network/Soroban RPC with one signed submission every few seconds per such subscription, for as long as it remains active and funded.

This is a plain missing-bound validation gap, but a consequential one given what actually consumes the field: unlike a display-only or cosmetic parameter, interval_seconds directly controls the rate of real on-chain fund movement by an autonomous background process.

Requirements

  • Add a minimum bound on interval_seconds at creation time — e.g. a MIN_SUBSCRIPTION_INTERVAL_SECS constant (a reasonable floor might be measured in minutes/hours depending on the intended product use case for "subscriptions," but even a conservative floor like 60s materially closes the worst-case drain rate) — enforced in SubscriptionService::create, mirroring how MAX_CONSECUTIVE_FAILURES/KEEPER_BATCH_LIMIT are already defined as named constants in this same file.
  • Add the equivalent CHECK constraint at the database level (CHECK (interval_seconds >= <min>)) so the floor holds even against a future code path that bypasses the service layer.
  • Consider whether an upper bound is also worth enforcing (extremely large but finite values are less operationally dangerous than tiny ones, but are handled separately — see the companion issue on interval_seconds near i64::MAX causing a chrono::Duration panic, which this issue's fix does not itself resolve).

Acceptance Criteria

  • SubscriptionService::create rejects interval_seconds below the configured minimum with AppError::Validation.
  • A new migration adds the matching CHECK constraint to the subscriptions table.
  • A test asserts interval_seconds = 1 (or any value below the chosen floor) is rejected at creation, and that a value at/above the floor succeeds.
  • The chosen minimum is documented (as a comment on the constant, and ideally in docs/API.md) so it's an intentional product decision, not an arbitrary magic number.

Additional Notes

Edge cases

  • The floor should be validated against first_execution_at/rescheduling too, not just the initial creation value — since interval_seconds is immutable after creation in the current API (there's no update endpoint, only cancel), a creation-time check is sufficient today, but worth a note if an "update subscription" endpoint is ever added.

Testing strategy

  • A straightforward unit test on SubscriptionService::create analogous to the existing positive/negative-amount validation tests already present in sibling services (e.g. services/payment_request.rs's test module).

Cross-references

  • Distinct from the companion issue about interval_seconds near i64::MAX panicking chrono::Duration::seconds() — that's an upper-bound crash/DoS bug; this is a lower-bound business-logic/fund-safety gap. Both point at the same field but are different bug classes requiring different fixes (one needs a floor, the other needs a ceiling well below the panic threshold).

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbackendBackend service logicbugSomething isn't workingvery hardVery difficult / senior-level bounty issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions