Skip to content

[REVIEW] api-security: add cursor pagination tenant-boundary and snapshot consistency gates #2254

@andycana

Description

@andycana

Skill Being Reviewed

Skill name: api-security
Skill path: skills/appsec/api-security/

False Positive Analysis

Benign code that could be over-flagged by the current pagination guidance:

@app.route("/api/v1/transactions")
@require_auth
def list_transactions():
    cursor = request.args.get("cursor")
    claims = verify_cursor(cursor, audience="transactions:list") if cursor else None
    page_size = 50

    if claims and claims["tenant_id"] != current_user.tenant_id:
        abort(403)

    rows = (
        Transaction.query
        .filter(Transaction.tenant_id == current_user.tenant_id)
        .filter(Transaction.created_at < claims["last_seen_at"] if claims else True)
        .order_by(Transaction.created_at.desc(), Transaction.id.desc())
        .limit(page_size + 1)
        .all()
    )

    return jsonify({
        "items": [row.to_dict() for row in rows[:page_size]],
        "next_cursor": sign_cursor({
            "tenant_id": current_user.tenant_id,
            "last_seen_at": rows[-1].created_at.isoformat(),
            "last_id": rows[-1].id,
            "aud": "transactions:list",
            "exp": int(time.time()) + 900,
        }) if len(rows) > page_size else None,
    })

Why this is a false positive:

The existing API4 guidance says to enforce a maximum pagination size, which is correct for limit-based pagination. A reviewer can still over-report an endpoint as missing a client-visible max limit when it intentionally uses server-fixed cursor pagination. The safe property is not that the client can see a numeric limit. The safe property is that page size is enforced server-side and the cursor is opaque, signed, tenant-bound, audience-bound, expiry-bound, and tied to a stable sort key.

Coverage Gaps

Missed variant 1: client-controlled cursor payload changes tenant or filter scope

@app.route("/api/v1/accounts")
@require_auth
def list_accounts():
    cursor = json.loads(base64.urlsafe_b64decode(request.args["cursor"]))
    tenant_id = cursor.get("tenant_id", current_user.tenant_id)
    after_id = cursor.get("after_id")

    return jsonify([
        a.to_dict()
        for a in Account.query
            .filter(Account.tenant_id == tenant_id)
            .filter(Account.id > after_id)
            .order_by(Account.id.asc())
            .limit(100)
            .all()
    ])

Why it should be caught:

This is both API1/BOLA and API4 resource-control risk. The endpoint has a numeric limit, so the current API4 checklist can pass it, but the cursor is user-controlled authorization state. A user can alter tenant_id, after_id, sort, or filter fields inside the cursor and traverse data outside the intended relationship boundary. The skill should require evidence that cursors are generated by the server, integrity-protected, and revalidated against the authenticated principal on every request.

Missed variant 2: unstable cursor ordering causes duplicates, skips, and scan amplification

app.get("/api/v1/audit-events", requireAuth, async (req, res) => {
  const cursor = JSON.parse(Buffer.from(req.query.cursor || "{}", "base64url"));
  const rows = await db.auditEvents.findMany({
    where: {
      tenantId: req.user.tenantId,
      createdAt: { lt: cursor.createdAt || new Date() }
    },
    orderBy: { createdAt: "desc" },
    take: 100
  });

  res.json({
    items: rows,
    next_cursor: Buffer.from(JSON.stringify({
      createdAt: rows.at(-1)?.createdAt
    })).toString("base64url")
  });
});

Why it should be caught:

The page size is capped, but the cursor is not tied to a unique stable sort tuple such as (created_at, id), and it is not bound to a consistent snapshot. Concurrent inserts with identical timestamps can create duplicate or skipped records. Attackers can replay or manipulate cursors to force repeated expensive scans, enumerate event density, or bypass completeness assumptions in audit/export workflows. The current checklist covers maximum page size, but not cursor stability, replay, expiry, or snapshot-consistency evidence.

Edge Cases

  • Opaque cursors that are encrypted but not authenticated can still be malleable depending on the mode and implementation.
  • Signed cursors can still be unsafe if they omit tenant ID, user ID, query shape, sort order, audience, expiry, or page-size policy version.
  • Cursor reuse across endpoints can leak data if a cursor issued for a low-sensitivity list endpoint is accepted by a high-sensitivity endpoint.
  • Admin or support endpoints need explicit actor-scope fields because tenant_id == current_user.tenant_id is not sufficient for delegated access.
  • GraphQL Relay-style cursors need the same integrity and scope checks; base64 encoding a database ID is not an authorization boundary.

Remediation Quality

  • Fix resolves the vulnerability
  • Fix doesn't introduce new security issues
  • Fix doesn't break functionality
  • Issues found: The skill should add a dedicated cursor-pagination gate instead of only adding "use signed cursors" as a short recommendation. Reviewers need to verify server-fixed page size, stable sort tuple, principal and tenant binding, query-shape binding, audience binding, expiry, replay tolerance, and snapshot or high-watermark behavior.

Recommended gates:

  1. API-CURSOR-01: Cursor integrity and scope gate. Require cursors to be server-issued, integrity-protected, tenant/principal-bound, audience-bound to one endpoint/query shape, expiry-bound, and revalidated against current authorization on every request.
  2. API-CURSOR-02: Stable ordering and snapshot gate. Require a deterministic unique sort tuple and document whether the list is snapshot-consistent, high-watermark based, or explicitly eventually consistent.
  3. API-CURSOR-03: Replay and cost-control gate. Require fixed server-side page size, bounded cursor lifetime, safe behavior for replayed/stale cursors, and monitoring for repeated cursor scans or query-shape abuse.

Comparison to Other Tools

Tool Catches this? Notes
Semgrep Partial Custom rules can catch obvious base64/JSON cursor trust or missing signature helpers, but they usually cannot prove tenant binding or stable snapshot semantics without project-specific knowledge.
CodeQL Partial Dataflow can detect user-controlled cursor fields reaching queries, but query-shape binding, cursor audience, and snapshot semantics usually need manual review.
DAST/ZAP No/Partial DAST may detect tampered cursor responses if test users and datasets are configured, but it rarely proves stable ordering or replay/cost behavior.

Overall Assessment

Strengths:

The api-security skill gives a strong OWASP API Top 10 structure. It correctly covers BOLA, GraphQL authorization, list endpoint filtering, and maximum pagination size.

Needs improvement:

The current guidance treats pagination mostly as a resource-consumption limit. Cursor pagination also carries authorization and consistency state. A capped page size is not enough when the cursor itself can alter tenant scope, query filters, sorting, or scan position.

Priority recommendations:

  1. Add cursor-specific evidence gates under API1 and API4.
  2. Add examples showing a safe signed, tenant-bound cursor and an unsafe decoded cursor trusted as query state.
  3. Add checklist items for stable sort tuples, cursor expiry, endpoint audience, replay behavior, and snapshot/high-watermark semantics.

Bounty Info

  • I have read and agree to the CONTRIBUTING.md bounty terms
  • Preferred payment method: Crypto

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions