feat(security): add per-IP rate limiting on guest-order endpoints (#19) #29

Open
hermes wants to merge 1 commit from fix/guest-order-rate-limiting into master
Collaborator

Why

Issue #19: POST /api/guest-orders is fully public (permitAll) with no throttling or IP-based limiting. An attacker can flood the orders table (DB bloat/DoS) and mass-generate self-confirmed paid orders.

Changes

  • GuestOrderRateLimitFilter (@Component, OncePerRequestFilter): in-memory sliding-window rate limiter
    • POST /api/guest-orders: 5 req/min per IP (configurable via app.rate-limit.guest-create)
    • Other /api/guest-orders/**: 20 req/min per IP (configurable via app.rate-limit.guest-default)
    • Returns HTTP 429 with Swedish JSON error
    • Respects X-Forwarded-For and X-Real-IP headers
  • SecurityConfig: filter registered before JWT filter
  • GuestOrderControllerTest: high limits (1000) + reset() in @BeforeEach

Phase 0 interim: in-memory, per-JVM, resets on restart. For production, use Bucket4j + Redis or nginx limit_req.

Test plan

  • ./gradlew :backend:test — BUILD SUCCESSFUL

Closes #19

## Why Issue #19: `POST /api/guest-orders` is fully public (`permitAll`) with no throttling or IP-based limiting. An attacker can flood the orders table (DB bloat/DoS) and mass-generate self-confirmed paid orders. ## Changes - **GuestOrderRateLimitFilter** (`@Component`, `OncePerRequestFilter`): in-memory sliding-window rate limiter - POST /api/guest-orders: 5 req/min per IP (configurable via `app.rate-limit.guest-create`) - Other /api/guest-orders/**: 20 req/min per IP (configurable via `app.rate-limit.guest-default`) - Returns HTTP 429 with Swedish JSON error - Respects `X-Forwarded-For` and `X-Real-IP` headers - **SecurityConfig**: filter registered before JWT filter - **GuestOrderControllerTest**: high limits (1000) + `reset()` in `@BeforeEach` Phase 0 interim: in-memory, per-JVM, resets on restart. For production, use Bucket4j + Redis or nginx `limit_req`. ## Test plan - [x] `./gradlew :backend:test` — BUILD SUCCESSFUL Closes #19
hermes added 1 commit 2026-07-18 11:45:28 +00:00
feat(security): add per-IP rate limiting on guest-order endpoints
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m49s
CI / E2E browser tests (pull_request) Failing after 1m45s
68cb20edba
POST /api/guest-orders is fully public (permitAll) with no captcha,
throttling, or IP-based limiting. An attacker can flood the orders table
(DB bloat / DoS) and mass-generate self-confirmed paid orders.

Changes:
  - GuestOrderRateLimitFilter: in-memory sliding-window rate limiter
    (OncePerRequestFilter + @Component). Per-IP limits:
      POST /api/guest-orders: 5 req/min (configurable)
      Other /api/guest-orders/**: 20 req/min (configurable)
    Returns 429 with Swedish JSON error when exceeded.
    Respects X-Forwarded-For and X-Real-IP headers.
  - SecurityConfig: register the filter before JWT filter
  - GuestOrderControllerTest: set high limits (1000) and reset filter
    state in @BeforeEach to avoid cross-test interference

Config via application properties:
  app.rate-limit.guest-create (default 5)
  app.rate-limit.guest-default (default 20)

Phase 0 interim: in-memory, per-JVM, resets on restart. For production,
use Bucket4j + Redis or nginx limit_req.

Closes #19
hermes reviewed 2026-07-18 11:47:11 +00:00
hermes left a comment
Author
Collaborator

Advisory review: ship with follow-up tracked for Phase 0 hardening.

Solid implementation of a sliding-window per-IP rate limiter. Clean code, good Javadoc, correct filter ordering. A few items below are worth addressing before this sees real traffic.


Warnings

X-Forwarded-For trust is unconditional
extractClientIp trusts X-Forwarded-For / X-Real-IP by default. If the app is ever directly reachable (no reverse proxy in front, or proxy doesn't strip client-supplied headers), an attacker can rotate the header value per request to bypass rate limiting entirely. Consider a config flag like app.rate-limit.trust-forwarded-headers (default false) that only trusts forwarded headers when explicitly enabled. The PR description mentions "respects" these headers but doesn't note the spoofing risk.

Unbounded store map growth
ConcurrentHashMap<String, Deque<Instant>> never removes entries. Stale IP keys (where the deque has been pruned to empty) persist forever. Combined with X-Forwarded-For spoofing, an attacker can cause unbounded memory growth by sending requests with unique header values. A scheduled cleanup (evict entries with empty deques every N minutes) or a bounded cache (Caffeine with maximumSize) would address this. Acceptable for Phase 0 but should be tracked.

Suggestions

No dedicated rate-limit tests
The test changes only raise limits to 1000 and add reset(). There are no tests that actually verify the rate-limiting behavior (e.g., fire createLimit + 1 requests, assert the last returns 429 with the expected body). For a security control, the core logic should be directly tested, not just bypassed. Consider a separate GuestOrderRateLimitFilterTest with low configured limits.

Use existing ErrorResponse DTO instead of hardcoded JSON
The codebase already has ErrorResponse(String message) and SecurityConfig uses objectMapper.writeValueAsString(new ErrorResponse(message)) for all other error responses. The filter handcrafts a raw JSON string instead ("{\"message\":\"...\"}"). Inject ObjectMapper and follow the existing pattern for consistency and to avoid escape fragility.

Add Retry-After header on 429
RFC 6585 recommends including Retry-After on 429 responses. Since the window is known (60s), adding response.setHeader("Retry-After", "60") is trivial and helps well-behaved clients back off.

isCreatePath doesn't handle trailing slash
path.equals("/api/guest-orders") won't match /api/guest-orders/. Spring typically normalizes this, but some clients or proxies may not. Consider path.equals("/api/guest-orders") || path.equals("/api/guest-orders/") or normalizing first.

Looks Good

  • Sliding-window algorithm with proper pruning of expired timestamps is correct
  • Thread-safe: computeIfAbsent + synchronized on the deque is the right pattern
  • Filter registered before JWT filter (blocks before expensive auth)
  • Sensible limit separation: 5/min for create, 20/min for read/pay
  • @Component auto-detection, @Value configuration, and reset() for test isolation are all well done
  • Excellent Javadoc explaining the algorithm, limitations, and Phase 0 status
**Advisory review: ship with follow-up tracked for Phase 0 hardening.** Solid implementation of a sliding-window per-IP rate limiter. Clean code, good Javadoc, correct filter ordering. A few items below are worth addressing before this sees real traffic. --- ### Warnings **X-Forwarded-For trust is unconditional** `extractClientIp` trusts `X-Forwarded-For` / `X-Real-IP` by default. If the app is ever directly reachable (no reverse proxy in front, or proxy doesn't strip client-supplied headers), an attacker can rotate the header value per request to bypass rate limiting entirely. Consider a config flag like `app.rate-limit.trust-forwarded-headers` (default `false`) that only trusts forwarded headers when explicitly enabled. The PR description mentions "respects" these headers but doesn't note the spoofing risk. **Unbounded `store` map growth** `ConcurrentHashMap<String, Deque<Instant>>` never removes entries. Stale IP keys (where the deque has been pruned to empty) persist forever. Combined with X-Forwarded-For spoofing, an attacker can cause unbounded memory growth by sending requests with unique header values. A scheduled cleanup (evict entries with empty deques every N minutes) or a bounded cache (Caffeine with `maximumSize`) would address this. Acceptable for Phase 0 but should be tracked. ### Suggestions **No dedicated rate-limit tests** The test changes only raise limits to 1000 and add `reset()`. There are no tests that actually verify the rate-limiting behavior (e.g., fire `createLimit + 1` requests, assert the last returns 429 with the expected body). For a security control, the core logic should be directly tested, not just bypassed. Consider a separate `GuestOrderRateLimitFilterTest` with low configured limits. **Use existing `ErrorResponse` DTO instead of hardcoded JSON** The codebase already has `ErrorResponse(String message)` and `SecurityConfig` uses `objectMapper.writeValueAsString(new ErrorResponse(message))` for all other error responses. The filter handcrafts a raw JSON string instead (`"{\"message\":\"...\"}"`). Inject `ObjectMapper` and follow the existing pattern for consistency and to avoid escape fragility. **Add `Retry-After` header on 429** RFC 6585 recommends including `Retry-After` on 429 responses. Since the window is known (60s), adding `response.setHeader("Retry-After", "60")` is trivial and helps well-behaved clients back off. **`isCreatePath` doesn't handle trailing slash** `path.equals("/api/guest-orders")` won't match `/api/guest-orders/`. Spring typically normalizes this, but some clients or proxies may not. Consider `path.equals("/api/guest-orders") || path.equals("/api/guest-orders/")` or normalizing first. ### Looks Good - Sliding-window algorithm with proper pruning of expired timestamps is correct - Thread-safe: `computeIfAbsent` + `synchronized` on the deque is the right pattern - Filter registered before JWT filter (blocks before expensive auth) - Sensible limit separation: 5/min for create, 20/min for read/pay - `@Component` auto-detection, `@Value` configuration, and `reset()` for test isolation are all well done - Excellent Javadoc explaining the algorithm, limitations, and Phase 0 status
Author
Collaborator

🟡 Unbounded map growth. Stale IP keys (pruned deques) are never removed from the map. With X-Forwarded-For spoofing, an attacker can cause unbounded memory growth by sending unique header values. Consider a scheduled cleanup task or a bounded cache (Caffeine maximumSize).

🟡 **Unbounded map growth.** Stale IP keys (pruned deques) are never removed from the map. With X-Forwarded-For spoofing, an attacker can cause unbounded memory growth by sending unique header values. Consider a scheduled cleanup task or a bounded cache (Caffeine `maximumSize`).
Author
Collaborator

💡 Add Retry-After header. response.setHeader("Retry-After", "60") is trivial and helps well-behaved clients back off. RFC 6585 recommends it for 429 responses.

💡 **Add `Retry-After` header.** `response.setHeader("Retry-After", "60")` is trivial and helps well-behaved clients back off. RFC 6585 recommends it for 429 responses.
Author
Collaborator

💡 Use the existing ErrorResponse DTO. The codebase has ErrorResponse(String message) and SecurityConfig uses objectMapper.writeValueAsString(new ErrorResponse(message)) for all error responses. Inject ObjectMapper and follow the same pattern to avoid hardcoded JSON and keep responses consistent.

💡 **Use the existing `ErrorResponse` DTO.** The codebase has `ErrorResponse(String message)` and SecurityConfig uses `objectMapper.writeValueAsString(new ErrorResponse(message))` for all error responses. Inject `ObjectMapper` and follow the same pattern to avoid hardcoded JSON and keep responses consistent.
Author
Collaborator

🟡 X-Forwarded-For is trusted unconditionally. If the app is directly reachable (no stripping reverse proxy), an attacker can rotate this header per request to bypass rate limiting entirely. Consider a config flag like app.rate-limit.trust-forwarded-headers (default false).

🟡 **X-Forwarded-For is trusted unconditionally.** If the app is directly reachable (no stripping reverse proxy), an attacker can rotate this header per request to bypass rate limiting entirely. Consider a config flag like `app.rate-limit.trust-forwarded-headers` (default `false`).
Author
Collaborator

💡 The rate limiter is wired in but never actually tested. No test fires createLimit + 1 requests to verify a 429 is returned. For a security control, consider a dedicated GuestOrderRateLimitFilterTest with low limits that exercises the actual rejection path.

💡 The rate limiter is wired in but never actually tested. No test fires `createLimit + 1` requests to verify a 429 is returned. For a security control, consider a dedicated `GuestOrderRateLimitFilterTest` with low limits that exercises the actual rejection path.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m49s
CI / E2E browser tests (pull_request) Failing after 1m45s
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/guest-order-rate-limiting:fix/guest-order-rate-limiting
git checkout fix/guest-order-rate-limiting

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git checkout master
git merge --no-ff fix/guest-order-rate-limiting
git checkout fix/guest-order-rate-limiting
git rebase master
git checkout master
git merge --ff-only fix/guest-order-rate-limiting
git checkout fix/guest-order-rate-limiting
git rebase master
git checkout master
git merge --no-ff fix/guest-order-rate-limiting
git checkout master
git merge --squash fix/guest-order-rate-limiting
git checkout master
git merge --ff-only fix/guest-order-rate-limiting
git checkout master
git merge fix/guest-order-rate-limiting
git push origin master
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: jocke/bilhej#29
No description provided.