Skip to content

fix: stop chainlit's Socket.IO transport from tripping the rate limiter#654

Open
Ahmath-Gadji wants to merge 5 commits into
developfrom
fix/chainlit-rate-limit
Open

fix: stop chainlit's Socket.IO transport from tripping the rate limiter#654
Ahmath-Gadji wants to merge 5 commits into
developfrom
fix/chainlit-rate-limit

Conversation

@Ahmath-Gadji

@Ahmath-Gadji Ahmath-Gadji commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

A chat session floods the API log with:

WARNING | api.middleware.rate_limit:dispatch:101 - Rate limit exceeded [path=/chainlit/ws/socket.io/ | tier=default | identity=ip:172.20.0.3]

Three defects compound here.

1. Chainlit's Socket.IO traffic is rate limited, but can't be attributed. mount_chainlit() mounts Chainlit's whole ASGI app — including its Socket.IO server at /chainlit/ws/socket.io — inside the API. That subtree is auth-bypassed (is_bypass_path), because Chainlit runs its own header-auth callback, so AuthMiddleware returns without setting request.state.user. RateLimitMiddleware then finds no user: _is_admin() is False even for an admin's browser, and _identity() falls back to request.client.host.

2. That IP is the proxy, not the browser. entrypoint.sh runs the bare uvicorn CLI, which reads FORWARDED_ALLOW_IPS — not the UVICORN_FORWARDED_ALLOW_IPS that api/main.py reads and that env_vars.md documents. The documented variable was a no-op on the default container path. So nginx's X-Forwarded-For is untrusted, request.client.host is the admin-ui container (172.20.0.3), and every chat user in the deployment shares one 600/minute bucket.

3. The transport should never have been HTTP. openrag-admin.conf's location / proxied over nginx's default HTTP/1.0 with no Upgrade/Connection headers, so Socket.IO's WebSocket upgrade always failed and it stayed on long-polling. Under long-polling each emitted packet terminates the open GET and the client re-polls, so a single streamed answer costs hundreds of requests. The resulting 429s look like transport errors to engine.io, which reconnects — and issues more requests.

Note SUPER_ADMIN_MODE is unrelated: the limiter never reads it, and the is_admin bypass it resembles cannot fire on a path where auth never ran.

Changes

One commit each:

  • fix(rate-limit): exempt the auth-bypassed chainlit subtree — skip the limiter on /chainlit and /assets/, overridable via the new RATE_LIMIT_EXEMPT_PATHS (set-but-empty disables the exemption). A per-identity limit that cannot distinguish identities only throttles everyone at once. /auth/* stays limited — it's the brute-force surface and IP keying is the point there.
  • fix(nginx): forward WebSocket upgrades to the APIproxy_http_version 1.1 plus the standard $connection_upgrade map. With the upgrade succeeding, chainlit chat becomes one WebSocket, whose ASGI scope is websocket, which Starlette's BaseHTTPMiddleware skips — so it stops traversing the limiter entirely. This fixes the load; the exemption fixes the accounting.
  • fix(entrypoint): honour UVICORN_FORWARDED_ALLOW_IPS on the uvicorn CLI path — forward the documented variable to --forwarded-allow-ips so per-user rate limits key on real clients. This gap also made OIDC session cookies ship Secure=False behind a TLS-terminating proxy, so it's worth fixing independently of the above.

Verification

  • pytest tests/unit/api/ — 261 passed. The rate-limit suite grew from 10 to 15 tests: chainlit and /assets/ exempt under a 2/minute budget, non-exempt routes still 429, env override replaces the defaults, set-but-empty disables them.
  • nginx -t against nginx:alpine with the new conf mounted — syntax OK.
  • New regression test asserts entrypoint.sh passes --forwarded-allow-ips from UVICORN_FORWARDED_ALLOW_IPS, mirroring the existing guard on api/main.py.
  • Not yet exercised end-to-end against a live stack — the WebSocket-upgrade path in particular is verified by config review and nginx -t, not by watching a browser negotiate the upgrade.

Operator notes

UVICORN_FORWARDED_ALLOW_IPS now takes effect where it previously didn't. It stays defaulted to 127.0.0.1 (unchanged behaviour, still ignores the proxy) — deployments wanting real client IPs must set it to their subnet or *. .env.example documents this, mirrored to docs/assets/env_example.env.

Summary by CodeRabbit

  • New Features

    • Added configurable path exemptions for rate limiting, including Chainlit and static assets by default.
    • Improved reverse-proxy support by honoring trusted forwarded headers.
    • Enabled WebSocket connections through the nginx deployment configuration.
  • Documentation

    • Expanded environment-variable guidance for rate-limit exemptions and trusted proxy settings.
    • Added security recommendations for forwarded headers, client IP handling, and proxy configuration.
  • Tests

    • Added coverage for exemption boundaries, configuration overrides, proxy settings, and WebSocket-related behavior.

/chainlit and /assets/ are bypassed by AuthMiddleware (Chainlit runs its own
header-auth callback), so request.state.user is never populated there. The rate
limiter therefore could not see the admin flag and fell back to keying on
request.client.host — which behind the admin-ui reverse proxy is one container
IP shared by every chat user. A single bucket for the whole deployment.

Chainlit's Socket.IO transport lives at /chainlit/ws/socket.io and issues one
HTTP request per emitted packet whenever it long-polls, so a streamed answer
alone exhausts the 600/minute default and the resulting 429s trigger engine.io
reconnects, which issue more requests.

Skip the limiter on those prefixes, overridable via RATE_LIMIT_EXEMPT_PATHS
(set-but-empty disables the exemption). /auth/* stays limited: it is the
brute-force surface and IP keying is exactly what it wants.
The `location /` block proxied over nginx's default HTTP/1.0 and dropped the
Upgrade/Connection headers, so chainlit's Socket.IO could never leave its
long-polling bootstrap transport. Every emitted packet then cost one HTTP
request through the API's middleware stack.

Add proxy_http_version 1.1 plus the standard $connection_upgrade map. The map
is load-bearing: a hardcoded `Connection: upgrade` would be sent on ordinary
requests too and break them.

With the upgrade succeeding, chainlit's chat traffic becomes a single WebSocket
whose ASGI scope is "websocket", which Starlette's BaseHTTPMiddleware skips —
so it no longer traverses the rate limiter at all.

Validated with `nginx -t` against nginx:alpine.
…I path

The documented UVICORN_FORWARDED_ALLOW_IPS was only read by api.main's
__main__ block, which entrypoint.sh executes solely when ENABLE_RAY_SERVE=true.
The default container path is the bare `uvicorn api.main:app` CLI, and uvicorn's
own config reads FORWARDED_ALLOW_IPS instead — so setting the documented
variable did nothing there.

Consequence: the admin-ui reverse proxy sits outside loopback, is never
trusted, X-Forwarded-For is dropped, and request.client.host stays the proxy's
container address. Every user shares one rate-limit bucket, and OIDC cookies
ship Secure=False behind HTTPS.

Pass --forwarded-allow-ips (and --proxy-headers explicitly) from the same
variable so one documented name covers both entrypoints. Guard it with a
regression test alongside the existing api.main one, and document the
rate-limit consequence plus RATE_LIMIT_EXEMPT_PATHS in .env.example (mirrored
to docs/assets) and env_vars.md.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3e78e916-20f6-4fd4-8a93-ae95d94f7859

📥 Commits

Reviewing files that changed from the base of the PR and between 760531e and 08d2477.

📒 Files selected for processing (8)
  • docs/assets/env_example.env
  • docs/content/docs/documentation/env_vars.md
  • infra/compose/.env.example
  • infra/compose/nginx/openrag-admin.conf
  • infra/scripts/entrypoint.sh
  • openrag/api/middleware/rate_limit.py
  • tests/unit/api/middleware/test_rate_limit.py
  • tests/unit/api/test_main_proxy_headers.py

📝 Walkthrough

Walkthrough

Rate-limit path exemptions are configurable and tested, Uvicorn now trusts explicitly configured proxy addresses, and nginx forwards WebSocket upgrades. Environment examples and documentation describe the new settings and reverse-proxy requirements.

Changes

Rate limiting and proxy integration

Layer / File(s) Summary
Rate-limit exemption behavior
openrag/api/middleware/rate_limit.py, tests/unit/api/middleware/test_rate_limit.py, docs/content/docs/documentation/env_vars.md
Adds configurable /chainlit/ and /assets/ exemptions, prefix-boundary handling, startup logging, and tests for overrides and disabled exemptions.
Trusted proxy forwarding configuration
infra/scripts/entrypoint.sh, tests/unit/api/test_main_proxy_headers.py, docs/assets/env_example.env, infra/compose/.env.example, docs/content/docs/documentation/env_vars.md
Passes proxy-header and forwarded-IP settings to Uvicorn and documents trusted proxy configuration and client-IP behavior.
Nginx WebSocket forwarding
infra/compose/nginx/openrag-admin.conf
Configures HTTP/1.1 upgrade forwarding with conditional Connection headers.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • linagora/openrag#606: Changes authentication bypass handling for Chainlit and asset paths related to the new rate-limit exemptions.
  • linagora/openrag#612: Updates RateLimitMiddleware dispatch behavior and related tests.
  • linagora/openrag#620: Modifies rate-limiter initialization and environment configuration.

Suggested labels: fix

Suggested reviewers: hedhoud, andyne13

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main user-facing fix: Chainlit Socket.IO traffic no longer trips rate limiting.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/chainlit-rate-limit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The exempt prefixes were matched with `startswith(("/chainlit", "/assets/"))`,
so any path merely *beginning* with `/chainlit` — e.g. `/chainlithack` — was
silently skipped by the limiter while AuthMiddleware still required a token for
it. Tighten the prefix to `/chainlit/` so only the real subtree is exempt,
mirroring the prefix arm of `api.middleware.auth.is_bypass_path`. Bare
`/chainlit` (the 307 to `/chainlit/`) is now metered at the default tier, which
is one request per page load and never bites.

Add a boundary regression test asserting `/chainlithack` stays rate-limited.
…ion boundary

Sync the documented `RATE_LIMIT_EXEMPT_PATHS` default to `/chainlit/,/assets/`
and note that prefixes are matched with `startswith`, so the trailing slash is
what keeps siblings like `/chainlithack` limited.

Add proxy-trust guidance for `UVICORN_FORWARDED_ALLOW_IPS`: prefer the proxy's
subnet over `*`. `*` trusts `X-Forwarded-For` from any peer, so when the API
port is reachable directly a caller can forge a rotating client IP and evade the
per-IP `/auth/*` brute-force limit. The `.env.example` now shows a subnet
instead of `*`, and env_vars.md gains a caution covering the too-narrow
(collapsed bucket) vs too-wide (spoofable) failure modes plus the nginx
`X-Forwarded-For $remote_addr` overwrite and the real-ip module alternative.
@Ahmath-Gadji Ahmath-Gadji marked this pull request as ready for review July 10, 2026 09:53
@coderabbitai coderabbitai Bot added the fix Fix issue label Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Fix issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant