fix(cinny-web): per-client rate limits; own budget for decorations #36

Merged
jared merged 1 commits from fix/nginx-real-ip-decorations into main 2026-10-19 12:44:26 -04:00
Owner

Follow-up to #35 (matrix #22). Two rate-limit problems on chat.lotusguild.org; testing the new decorations host exposed both.

1. Everyone shared one rate-limit bucket (existing bug). All requests reach LXC 106 from Nginx Proxy Manager (10.10.10.27; it's the only address in the access log), and there was no realip config. So chat_limit (20 r/s, burst 60, keyed by $binary_remote_addr) applied to the whole community at once: when a scanner probed /admin, /login, … everyone got 503s together. Fix: set_real_ip_from 10.10.10.27; real_ip_header X-Real-IP; in both server blocks. Only the proxy is trusted; NPM sets X-Real-IP to the client address, overwriting anything the client sent.

2. The decoration picker would trip it. Opening the profile decoration picker can fetch all 629 thumbnails; a full run against production returned 500 × 503. Before, they came from Nextcloud, a different host. /decorations/ now has its own zone (100 r/s, burst 700) instead of chat_limit.

Tested in a local nginx harness (prod rate, realip pointed at the harness):

  • Picker (629 thumbnails, 16 at a time): 629 × 200; the app loads right after, for the same user.
  • A scanner flooding 150 requests is throttled (62 × 200, 88 × 503), while another user during the flood gets 200.
  • The log shows each client's own address; with the real proxy address trusted, a spoofed X-Real-IP from elsewhere is ignored.
  • nginx -t is OK. The deploy runs nginx -t with rollback.

After the deploy I'll check that the production log shows real client addresses.

🤖 Generated with Claude Code

https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5

Follow-up to #35 (matrix #22). Two rate-limit problems on chat.lotusguild.org; testing the new decorations host exposed both. **1. Everyone shared one rate-limit bucket (existing bug).** All requests reach LXC 106 from Nginx Proxy Manager (`10.10.10.27`; it's the only address in the access log), and there was no `realip` config. So `chat_limit` (20 r/s, burst 60, keyed by `$binary_remote_addr`) applied to **the whole community at once**: when a scanner probed `/admin`, `/login`, … everyone got 503s together. Fix: `set_real_ip_from 10.10.10.27; real_ip_header X-Real-IP;` in both server blocks. Only the proxy is trusted; NPM sets `X-Real-IP` to the client address, overwriting anything the client sent. **2. The decoration picker would trip it.** Opening the profile decoration picker can fetch all 629 thumbnails; a full run against production returned **500 × 503**. Before, they came from Nextcloud, a different host. `/decorations/` now has its own zone (100 r/s, burst 700) instead of `chat_limit`. **Tested in a local nginx harness** (prod rate, realip pointed at the harness): - Picker (629 thumbnails, 16 at a time): **629 × 200**; the app loads right after, for the same user. - A scanner flooding 150 requests is throttled (62 × 200, 88 × 503), while **another user during the flood gets 200**. - The log shows each client's own address; with the real proxy address trusted, a spoofed `X-Real-IP` from elsewhere is ignored. - `nginx -t` is OK. The deploy runs `nginx -t` with rollback. After the deploy I'll check that the production log shows real client addresses. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
jared added 1 commit 2026-10-19 12:41:47 -04:00
fix(cinny-web): per-client rate limits; own budget for decorations
Lint / Shell (shellcheck) (pull_request) Successful in 7s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / No secrets in webhook configs (pull_request) Successful in 3s
Lint / Landing page is rendered (matrix (pull_request) Successful in 3s
Lint / Python (ruff) (pull_request) Successful in 4s
Lint / Python deps (pip-audit) (pull_request) Successful in 49s
Lint / Secret scan (gitleaks) (pull_request) Successful in 5s
Lint / Shell (shellcheck) (push) Successful in 7s
Lint / JS (eslint) (push) Successful in 6s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 30s
Lint / Secret scan (gitleaks) (push) Successful in 4s
a52d6a89bc
Every request reaches LXC 106 from Nginx Proxy Manager (10.10.10.27), and
nginx had no realip config, so chat_limit (20 r/s, burst 60, keyed by
$binary_remote_addr) was ONE bucket for the whole community: a scanner
burst 503ed real users. Trust X-Real-IP from the proxy only, in both
server blocks.

Decorations (#22) now come from this host, and the profile picker can
fetch all 629 thumbnails: ~500 of them 503ed in testing. /decorations/
gets its own zone (100 r/s, burst 700) instead of chat_limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
jared merged commit f7ce35c636 into main 2026-10-19 12:44:26 -04:00
Sign in to join this conversation.