Skip to content

Enforce future-only purchase start_date server-side (defense in depth for #1249) #50

Closed

Description

@cristim

Problem

PR LeanerCloud/cloud-commitments-cli#1258 (closes LeanerCloud/cloud-commitments-cli#1249) added a min attribute to the Add Purchases start-date <input type="date"> so the browser date-picker blocks past dates. The PR title and commit message say "enforce future-only planned start date", but the constraint is only a UA-side hint — there is no JS validation in handleAddPurchases and no server-side validation in handler_plans.go::parseCreatePurchasesRequest.

Today's behaviour

  1. Frontend (frontend/src/plans.ts, handleAddPurchases at ~line 2110): reads add-purchases-start-date.value, passes it straight to api.createPlannedPurchases(planId, count, startDate) without checking that startDate >= today.
  2. Backend (internal/api/handler_plans.go::parseCreatePurchasesRequest, lines 327-343): only validates the date format (YYYY-MM-DD). A past date is silently accepted and the executions are inserted with ScheduledDate in the past.
  3. Scheduler (internal/purchase/manager.go:529):
    if exec.ScheduledDate.After(now) {
        logging.Debugf("...not yet due...")
        continue
    }
    A past ScheduledDate falls through the After(now) guard and is treated as due-now, so all past-dated executions fire on the next tick.

How to reproduce a bypass

Any of:

  • Firefox / Safari accept the form submission even when the value is < min (min is advisory in these browsers; Chrome does block).
  • Devtools: remove min attribute, pick a past date, submit.
  • Direct API call: POST /plans/{id}/purchases with {"count":1,"start_date":"2020-01-01"} — the backend accepts it.

Why this matters

A user (or a misbehaving client) scheduling a past date inserts count purchase rows that immediately become "due" and queue for execution. With Approval pending → notification fires for all of them at once; with auto-approval thresholds, they execute immediately. Either way the user-visible Plan state diverges from what the picker visually suggested.

Suggested fix

Add startDate < today rejection at both layers:

  1. Server (parseCreatePurchasesRequest):
    if startDate.Before(time.Now().UTC().Truncate(24 * time.Hour)) {
        return nil, time.Time{}, NewClientError(400, "start_date must be today or later")
    }
    Note: the comparison should account for the user's timezone — a Pacific-time user at 23:00 local Dec 31 sees "today = Dec 31" but UTC truncated-day = Jan 1, so a strict UTC compare risks rejecting their valid same-day submission. Options: accept any date >= (UTC today - 1 day) as today (cheap, tolerates worst-case offset), or take the user's timezone from a profile field. Discuss in the PR review.
  2. Client (handleAddPurchases):
    const todayLocal = toLocalDateInputValue(new Date()); // helper added in PR LeanerCloud/cloud-commitments-cli#1258
    if (startDate < todayLocal) {
        errorDiv.textContent = 'Start date must be today or later';
        errorDiv.classList.remove('hidden');
        return;
    }
    Keeps the Firefox/devtools path closed and gives a clear inline error before round-tripping.

Acceptance criteria

  • POST /plans/{id}/purchases with start_date < today returns 400 with a clear message.
  • Backend test that confirms a past date is rejected.
  • Frontend handleAddPurchases blocks the submit and surfaces the error inline (regression test in plans-range-validation.test.ts mirroring the count-range pattern).
  • No regression for today and today+N.
  • Decision recorded for timezone handling (UTC-truncate vs per-user TZ).

Related

Activity

  1. IAmJSD commented on Oct 16, 2026

    @IAmJSD
    Collaborator

    Claiming: working on this

  2. self-assigned this
    on Oct 16, 2026
  3. added a commit that references this issue on Oct 19, 2026
    2d822f3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions