Skip to content

feat: rate-limit guest ticket creation and replies per IP - #90

Merged
mpge merged 3 commits into
mainfrom
feat/guest-endpoint-throttle
Oct 5, 2026
Merged

mpge merged 3 commits into
mainfrom
feat/guest-endpoint-throttle

Conversation

@mpge

@mpge mpge commented Oct 5, 2026

Copy link
Copy Markdown
Member

Port of escalated-dev/escalated-nestjs#130 (the reference implementation).

Behaviour

  • Guarded endpoints, per client IP, each scope with its own counter over a fixed 60s window:
    • ticket: POST /wp-json/escalated/v1/widget/tickets and the escalated_guest_create AJAX action (guest form shortcode). Default 5/min.
    • reply: the escalated_guest_reply AJAX action. Default 10/min. The throttle runs before the guest token lookup, so wrong-token requests are counted.
  • Over the limit: 429 with a Retry-After header (seconds until the window resets). REST returns the usual WP_Error body with code escalated_guest_rate_limited; AJAX returns wp_send_json_error with code, message, retry_after.
  • Config follows the plugin's settings-table convention, with code defaults so existing installs get the limit without a migration:
    • guest_rate_limit_enabled (default 1), guest_rate_limit_tickets_per_minute (5), guest_rate_limit_replies_per_minute (10)
    • escalated_guest_rate_limit filter for hosts that configure in code.
  • Counter store: WordPress transients, following the existing widget / API-token / unsubscribe limiters. With a persistent object cache (Redis, Memcached) the counters are shared across servers; otherwise they live in the options table.
  • The existing widget-wide limit (30 req/min per IP across all widget endpoints) is untouched and still applies as a second layer. The port had no per-email limit to keep.
  • Proxies: the IP is REMOTE_ADDR. Documented in the class docblock, README and CHANGELOG that sites behind a proxy/CDN must rewrite REMOTE_ADDR from the trusted proxy's forwarded header, or every guest shares one IP.

Tests (written first, watched fail)

tests/Test_Guest_Rate_Limit.php (REST + limiter) and tests/Test_Guest_Ajax_Rate_Limit.php (AJAX), 17 tests:

  • 6th widget ticket in a minute -> 429 with Retry-After in (0, 60], and no ticket row created
  • 6th guest-form ticket -> limited; 11th guest reply -> limited, reply not stored
  • wrong-token replies count: limit 2 -> Ticket not found., Ticket not found., limited
  • configured limit honoured (setting and filter); an unset limit keeps its default
  • disabled -> 8 tickets and 12 replies all succeed
  • per-IP keying, separate ticket/reply counters, window expiry

Before wiring the endpoints, 10 of the 17 failed (the limiter-only and disabled cases passed).

Results

Run locally in Docker (wordpress:cli-php8.1, MySQL 8.0, WordPress 7.1.2 test suite, same layout as run-tests.yml):

  • vendor/bin/phpunit: OK, 563 tests, 1696 assertions, 4 skipped (main baseline: 546 tests, 1652 assertions, 4 skipped)
  • vendor/bin/pint --test: PASS, 220 files

Differences from the reference

  • The REST ticket check runs at the top of the route callback rather than as a guard, because WordPress validates required args before permission_callback/callback and WP_Error cannot carry a Retry-After header. Malformed bodies (400 from arg validation) are therefore not counted; everything that reaches ticket creation is.
  • WordPress has no guest REST reply endpoint; guest replies go through the escalated_guest_reply AJAX action, so that is what the reply scope guards. A wrong token there answers 404 (Ticket not found.) rather than NestJS's 403.
  • The AJAX throttle runs before the nonce check as well, so invalid-nonce floods are counted too.
  • Counters use a get/set on a transient (not an atomic increment), matching the plugin's existing limiters; concurrent bursts can slip a request or two past the limit.
  • The guest live-chat start endpoint (widget/chat/start) is not covered here, matching the reference's scope; it remains under the widget-wide 30/min limit.

mpge added 3 commits October 4, 2026 21:36
Adds GuestRateLimiter (transient-backed fixed 60s window per scope and
client IP, configurable through settings and a filter) and the tests that
pin the endpoint behaviour. The endpoints are not wired yet, so the
endpoint tests fail.
The widget ticket endpoint and the guest create/reply AJAX actions now
answer 429 with Retry-After once a client IP passes 5 tickets or 10
replies a minute. The reply check runs before the guest token lookup so
wrong-token guesses are counted.
@mpge
mpge merged commit 63585cb into main Oct 5, 2026
3 checks passed
@mpge mpge mentioned this pull request Oct 8, 2026
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.

1 participant