Key the client address on the connection, not a header the caller sends
client_ip() read the first X-Forwarded-For hop unconditionally. The login rate limit and the account lockout are keyed on that value and the audit log records it, so rotating the header per request bought an unbounded budget and an untraceable trail. This walked around TRUSTED_PROXY_COUNT, which exists to decide exactly this. The app leaves ProxyFix off by default and says why, ten lines of comment away: ProxyFix trusts X-Forwarded-*, a client can send those itself, and the login limiter is keyed on the address. Then client_ip read the header anyway, so the stated protection never applied. Resolution is now from remote_addr, which ProxyFix has already filled from the header wherever a proxy is genuinely configured. Both cases end at the same trustworthy source, and the port still comes off because ARR forwards clientip:port and ProxyFix passes it through verbatim. geenforce and tools had each hand-rolled the same mistake for their own limiters. Rather than duplicate security-relevant logic a third time, client_ip and strip_port join the shopdb.api surface (contract 0.28.0). geenforce's _trusted_client_ip, which already refused the raw header for its IP allowlist, is untouched and was the model for this. The stability table was also missing rows for 0.26.0 and 0.27.0. INSTALL-WINDOWS-IIS.md never mentioned TRUSTED_PROXY_COUNT, so a site installed from it runs behind IIS with the setting at 0: every request looks like it came from IIS, the whole site shares one rate-limit bucket, and the audit log records the proxy for every action. It now says so, and says not to set it when nothing is in front. Three test modules simulated separate callers by setting X-Forwarded-For, which is the header a real attacker rotates to escape the limit. One of them, test_client_ip_prefers_forwarded_first_hop_ stripped, asserted the defect outright. The suite could not have caught this. They now drive REMOTE_ADDR, with a regression guard that forging the header buys nothing. Scope: product
This commit is contained in:
@@ -21,7 +21,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from shopdb.api import (
|
||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||
require_any_permission,
|
||||
authorized_service_token, Setting, Application,
|
||||
authorized_service_token, Setting, Application, client_ip,
|
||||
)
|
||||
|
||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||
@@ -207,12 +207,14 @@ PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
def _client_ip():
|
||||
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
|
||||
(mirrors shopdb.core.api.auth._login_ip)."""
|
||||
forwarded = request.headers.get('X-Forwarded-For')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return request.remote_addr or 'unknown'
|
||||
"""Caller IP for rate limiting.
|
||||
|
||||
Uses the shared helper, which resolves from remote_addr. The raw
|
||||
X-Forwarded-For hop is client-supplied, so keying a rate limit on it lets a
|
||||
caller rotate the header and get an unbounded budget - the same reasoning
|
||||
_trusted_client_ip() above already applies to the allowlist.
|
||||
"""
|
||||
return client_ip(request) or 'unknown'
|
||||
|
||||
|
||||
def _payload_max_bytes():
|
||||
|
||||
@@ -37,7 +37,7 @@ from flask import current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, cache, Setting,
|
||||
db, cache, Setting, client_ip,
|
||||
success_response, error_response, ErrorCodes,
|
||||
)
|
||||
|
||||
@@ -117,8 +117,9 @@ def _guest_ratelimited():
|
||||
person has more addresses - but it stops an open endpoint being an easy
|
||||
place to park data or fill a disk.
|
||||
"""
|
||||
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.remote_addr or 'unknown')
|
||||
# Not the raw X-Forwarded-For hop: the caller sends that, so rotating it
|
||||
# gives an unbounded budget and the limit stops being a limit.
|
||||
address = client_ip(request) or 'unknown'
|
||||
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
|
||||
key = f'securesendguest:{address}:{bucket}'
|
||||
used = cache.get(key) or 0
|
||||
|
||||
Reference in New Issue
Block a user