A single index change bypassed daily_stock_analysis's entire rate limiter
A self-hosted stock analysis platform trusted the leftmost X-Forwarded-For entry for rate limiting, letting attackers rotate IPs and brute-force the admin login at will.

The get_client_ip() function in ZhuLinsen/daily_stock_analysis, a self-hosted stock analysis platform with a web UI, selected the leftmost entry from the X-Forwarded-For header to key its login rate limiter. That entry is controlled by the client. An attacker who rotated the header value on each request got a fresh rate-limit bucket every time, bypassing the platform's only brute-force protection on its admin login endpoint.
The fix, merged on 26 March 2026 via PR #841, changed a single character: [0] became [-1].
What daily_stock_analysis is and why it matters
daily_stock_analysis is an open-source Python application for tracking and analysing stock portfolios. It ships a FastAPI-based web UI with optional password authentication, Telegram notifications and LLM-powered market analysis. The project is actively maintained and includes detailed deployment guides for running behind Nginx as a reverse proxy on a cloud server.
The web UI has one admin account. When ADMIN_AUTH_ENABLED=true, login attempts are protected by a rate limiter: five failed attempts within five minutes locks out that IP. This is a reasonable defence for a single-user application. It becomes no defence at all when the attacker controls the IP the limiter sees.
The vulnerable code path
The function responsible sits in src/auth.py:
def get_client_ip(request) -> str:
"""Get client IP, respecting TRUST_X_FORWARDED_FOR."""
if os.getenv("TRUST_X_FORWARDED_FOR", "false").lower() == "true":
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host or "127.0.0.1"
return "127.0.0.1"The X-Forwarded-For header is a comma-separated list. When a request passes through a proxy, the proxy appends the connecting client's IP to the list. The leftmost entry is whatever the original client sent. The rightmost entry is the one the last trusted proxy appended.
By taking [0], the function handed the attacker direct control over the identity used for rate limiting.
This function is called in three places in api/v1/endpoints/auth.py:
- Line 268:
auth_update_settings(enabling auth), rate-limited password check - Line 295:
auth_update_settings(disabling auth), rate-limited password check - Line 377:
auth_login(main login endpoint), rate-limited password check
All three receive the raw FastAPI request object, which includes attacker-controlled HTTP headers. The login endpoint is exempt from authentication middleware (it is in EXEMPT_PATHS), so rate limiting is the only defence against brute force.
How exploitation works
The attack is trivial. Each request sends a different X-Forwarded-For value:
for i in $(seq 1 10000); do
curl -s -X POST https://target.example.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-H "X-Forwarded-For: 10.0.$((i / 256 % 256)).$((i % 256))" \
-d "{\"password\": \"$(sed -n ${i}p wordlist.txt)\"}"
doneEvery request lands in a unique rate-limit bucket. The five-attempt-per-five-minutes limit never triggers. With PBKDF2's ~200ms per attempt on the server side, a one-million-entry dictionary attack would complete in roughly 2.3 days rather than the intended 1.9 years.
Three preconditions are required:
TRUST_X_FORWARDED_FOR=true(the configuration the project's own documentation recommends for Nginx deployments)ADMIN_AUTH_ENABLED=true(the authentication feature must be active)- The application is internet-facing behind a reverse proxy (the documented deployment model)
All three are the expected production state according to the project's deployment guides.
The fix: one index, one character
The core change replaces [0] with [-1]:
- return forwarded.split(",")[0].strip()
+ return forwarded.split(",")[-1].strip()In a single trusted reverse proxy deployment (Nginx directly in front of the application), the proxy appends the real client IP as the rightmost entry via $proxy_add_x_forwarded_for. The client can prepend whatever it wants to the left side of the header, but cannot control the entry the proxy appends. Selecting [-1] means the rate limiter keys on the proxy-appended value, which the attacker cannot spoof.
The fix is correct for the deployment model the project documents. For multi-proxy chains (CDN in front of Nginx in front of the application), [-1] would return the edge proxy's IP rather than the real client, effectively collapsing all clients behind that CDN into a single rate-limit bucket. The updated documentation and .env.example comments now explicitly scope the feature to single-proxy deployments and warn about this limitation.
X-Forwarded-For: the header everyone parses wrong
This is not a novel vulnerability class. It is arguably one of the most well-documented mistakes in web application security, and it keeps happening because the X-Forwarded-For header is a fundamentally adversarial data structure dressed up as a helpful list.
MDN's documentation explicitly warns against selecting the leftmost entry and recommends selecting from the right based on the number of trusted proxies. OWASP documents IP spoofing via HTTP headers as a standard rate-limit bypass technique. CWE-345 (Insufficient Verification of Data Authenticity) covers the general class of trusting unverified client-supplied data.
The pattern recurs because frameworks and tutorials often demonstrate the leftmost approach. Stack Overflow answers, blog posts and even official framework documentation have historically shown X-Forwarded-For.split(',')[0] as the canonical way to extract a client IP. The rightmost-minus-N approach requires the developer to reason about their proxy topology, which is harder to put in a code snippet.
Rails has ActionDispatch::RemoteIp with a configurable trusted proxy list. Django has SECURE_PROXY_SSL_HEADER but leaves XFF parsing to middleware like django-xff. Express has trust proxy with numbered depth. FastAPI, which daily_stock_analysis uses, provides no built-in XFF handling at all, leaving it to application code. When the framework does not have an opinion, the developer writes .split(",")[0] and moves on.
What the review process revealed
The PR went through eight review iterations before being merged. The code change itself was never disputed: every reviewer agreed that [-1] was correct for the documented deployment model. The extended review cycle was driven by documentation requirements specific to the repository's contribution guidelines (AGENTS.md): missing rollback plans, missing changelog entries, changelog entries placed under the wrong version heading, missing explanations for why README.md was not updated and a merge conflict in the changelog file.
This is an interesting dynamic. A one-character security fix with a comprehensive test suite and detailed vulnerability analysis spent multiple days in review over process compliance rather than technical correctness. The repository's automated review bot eventually confirmed readiness, and the maintainer merged it after the final conflict resolution.
The ten-test regression suite included in the PR is thorough relative to the size of the change. It validates the core fix (rightmost selection), the attack scenario (leftmost value not returned), fallback behaviour (no header, no client, empty header), trust flag semantics (disabled, unset, case-insensitive) and whitespace handling. For a single-function security patch, that is notably more test coverage than most open-source projects require.
A pattern in self-hosted tools
This is the fourth case study on this blog involving a security fix contributed to an open-source project. Hugging Face's skills framework had five SQL injection vectors in a single file. gptme was passing API keys on the command line where any user on the system could read them. Hermes Agent's worktree feature copied arbitrary files from the host filesystem.
The common thread is not that these projects are poorly written. They are actively maintained, documented and in many cases backed by well-known organisations. The pattern is that self-hosted tools, particularly those with web interfaces and optional authentication, accumulate security-relevant code written by developers focused on functionality rather than adversarial input. Authentication, rate limiting and header parsing are not the interesting parts of a stock analysis platform. They are the parts that get written once, tested against the happy path and left alone.
daily_stock_analysis's rate limiter worked correctly in the scenario its developer probably tested: a single client behind a proxy making a few failed attempts. It failed in the scenario an attacker would create: a single client pretending to be thousands of different ones. The gap between those two scenarios is exactly one array index.
Self-hosted applications have a particular version of this problem. Unlike SaaS platforms, where a single team operates the deployment and can compensate for application-level weaknesses with infrastructure controls, self-hosted tools run in whatever environment the user sets up. The developer cannot assume a WAF will catch header spoofing. The developer cannot assume the operator will configure external rate limiting. The application's own defences are often the only ones that exist, which means they need to be correct on their own terms.
The fix here was minimal and the vulnerability straightforward. That is precisely why it is instructive. Nobody needs a novel attack technique to bypass a rate limiter that trusts the client to identify itself honestly. The attacker just needs a for loop and a header.
Newsletter
One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.