mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 20:22:46 +08:00
feat: Add IP-based rate limiting to login endpoint (#13469)
* feat(security): add failed login logging with IP tracking and comprehensive tests * fix: remove PII from login logs and fix structlog format * docs: agent improvements (#13269) * docs: add structured output for agents * docs: add structured output note * docs: clarify both agent outputs and add release notes * docs: when langflow sends events to the playground and chat history * fix(playground): expand session checkbox click target to full row height (#13349) * fix(playground): expand session checkbox click target to full row height The selectable-row checkbox wrapper was ``w-4 h-4`` (16x16 px) but the row is ``h-8`` (32 px). The 8 px band above and the 8 px band below the visible icon were dead zones that bubbled to the row's ``toggleVisibility`` and navigated to the session instead of toggling selection — exactly the accidental-navigation UX the bug ticket describes. Grow the wrapper to ``w-4 h-8`` so the click target captures the full row height. Width stays narrow so the text alignment is unchanged; the icon itself stays ``h-4 w-4``, centered via the existing ``flex items-center justify-center``. Visual position is identical. Adds ``__tests__/session-selector-checkbox-click-target.test.tsx`` (3 cases): wrapper className guard (h-8 + w-4, not h-4), click on the wrapper outside the icon triggers selection without bubbling to toggleVisibility, and click on the inner icon still toggles selection. * test(playground): drop checkbox-click-target test file (not needed per review) * [autofix.ci] apply automated fixes * test: align session-selector checkbox assertions with full-height click target --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: improve trace search functionality (#13383) * improve trace search functionality * [autofix.ci] apply automated fixes --------- Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * test: fix test_login_logs_real_output_format to use mocking - Updated test to use patch() instead of capsys for consistency - Verifies structlog kwargs format (not nested extra dict) - All 7 login logging tests now passing * feat: Add IP-based rate limiting to login endpoint - Add slowapi dependency for rate limiting functionality - Implement rate limiting service with configurable limits - Apply rate limiter to /login endpoint (default: 5 attempts/minute per IP) - Add custom exception handler for rate limit exceeded (HTTP 429) - Support both in-memory (default) and Redis storage backends - Add comprehensive test suite with 100% coverage - Document configuration in .env.example Security improvements: - Prevents brute force attacks on login endpoint - Structured logging without PII (logs client_ip, not user data) - Graceful degradation with swallow_errors=True - Proper X-Forwarded-For header handling for proxied requests Configuration: - LANGFLOW_RATE_LIMIT_PER_MINUTE: requests per minute (default: 5) - LANGFLOW_RATE_LIMIT_STORAGE: storage backend (default: memory://) - LANGFLOW_RATE_LIMIT_HEADERS_ENABLED: enable rate limit headers (default: false) * security: add proxy-aware IP extraction for rate limiting - Add LANGFLOW_RATE_LIMIT_TRUST_PROXY configuration - Default to secure get_remote_address (prevents header spoofing) - Enable get_client_ip when behind trusted proxies - Add comprehensive tests for both configurations - Maintain 100% test coverage (15 tests passing) * refactor(rate-limit): address PR review feedback - migrate to Settings model, fix IP security, add test coverage * fix: disable rate limiting in tests to prevent 429 errors - Add LANGFLOW_RATE_LIMIT_ENABLED environment variable control - Disable rate limiting by default in tests via session-scoped fixture - Rate limit tests explicitly enable it via function-scoped fixture - Fixes test failures caused by sequential login attempts hitting 5/min limit * fix: refactor login rate limiting to avoid settings initialization race condition --------- Co-authored-by: Janardan S Kavia <janardanskavia@Janardans-MacBook-Pro.local> Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com> Co-authored-by: keval shah <kevalvirat@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: olayinkaadelakun <olayinka.adelakun@ibm.com> Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local>
This commit is contained in:
committed by
GitHub
parent
b4a8b08414
commit
f76d3e22dd
31
.env.example
31
.env.example
@ -25,18 +25,17 @@ LANGFLOW_DATABASE_URL=sqlite:///./langflow.db
|
||||
# Alembic logs path flag. If set to true, Alembic will log to stdout.
|
||||
LANGFLOW_ALEMBIC_LOG_TO_STDOUT=False
|
||||
|
||||
|
||||
# mem0 creates a directory
|
||||
# mem0 creates a directory
|
||||
# for chat history, vector stores, and other artifacts
|
||||
# its default path is ~/.mem0.
|
||||
# we can change this path with
|
||||
# we can change this path with
|
||||
# environment variable "MEM0_DIR"
|
||||
# Example: MEM0_DIR=/tmp/.mem0
|
||||
|
||||
# composio creates a cache directory
|
||||
# composio creates a cache directory
|
||||
# for file uploads/downloads.
|
||||
# its default path is ~/.composio
|
||||
# we can change this path with
|
||||
# we can change this path with
|
||||
# environment variable "COMPOSIO_CACHE_DIR"
|
||||
# Example: COMPOSIO_CACHE_DIR=/tmp/.composio
|
||||
|
||||
@ -131,6 +130,28 @@ LANGFLOW_API_KEY_SOURCE=
|
||||
# Example: LANGFLOW_API_KEY=your-secure-api-key
|
||||
LANGFLOW_API_KEY=
|
||||
|
||||
# Rate Limiting Configuration
|
||||
# Controls rate limiting for login endpoint to prevent brute force attacks
|
||||
# LANGFLOW_RATE_LIMIT_PER_MINUTE: Number of login attempts allowed per minute per IP
|
||||
# Values: positive integer (default: 5)
|
||||
# Example: LANGFLOW_RATE_LIMIT_PER_MINUTE=10
|
||||
LANGFLOW_RATE_LIMIT_PER_MINUTE=
|
||||
|
||||
# LANGFLOW_RATE_LIMIT_STORAGE_URI: Storage backend for rate limit counters
|
||||
# Values: memory:// (default, single server) or redis://host:port (production, multiple servers)
|
||||
# - memory://: Fast, in-memory storage (not shared between server instances, lost on restart)
|
||||
# - redis://localhost:6379: Persistent, shared storage (required for load-balanced deployments)
|
||||
# Example: LANGFLOW_RATE_LIMIT_STORAGE_URI=redis://localhost:6379
|
||||
LANGFLOW_RATE_LIMIT_STORAGE_URI=
|
||||
|
||||
# LANGFLOW_RATE_LIMIT_TRUST_PROXY: Trust X-Forwarded-For header for client IP
|
||||
# Values: true, false (default: false)
|
||||
# When true, uses rightmost X-Forwarded-For IP (the trusted proxy before us)
|
||||
# When false, uses direct connection IP (prevents header spoofing in non-proxy deployments)
|
||||
# SECURITY: Only enable if behind a trusted proxy (nginx, load balancer, etc.)
|
||||
# Example: LANGFLOW_RATE_LIMIT_TRUST_PROXY=true
|
||||
LANGFLOW_RATE_LIMIT_TRUST_PROXY=
|
||||
|
||||
# Should store environment variables in the database
|
||||
# Values: true, false
|
||||
LANGFLOW_STORE_ENVIRONMENT_VARIABLES=
|
||||
|
||||
@ -2837,7 +2837,7 @@
|
||||
"filename": "src/backend/tests/conftest.py",
|
||||
"hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd",
|
||||
"is_verified": false,
|
||||
"line_number": 480,
|
||||
"line_number": 489,
|
||||
"is_secret": false
|
||||
},
|
||||
{
|
||||
@ -2845,7 +2845,7 @@
|
||||
"filename": "src/backend/tests/conftest.py",
|
||||
"hashed_secret": "61fbb5a12cd7b1f1fe1624120089efc0cd299e43",
|
||||
"is_verified": false,
|
||||
"line_number": 690,
|
||||
"line_number": 699,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
@ -9287,5 +9287,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-06-02T14:40:31Z"
|
||||
"generated_at": "2026-06-03T21:32:15Z"
|
||||
}
|
||||
|
||||
@ -4,7 +4,10 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from limits import parse
|
||||
from pydantic import BaseModel
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.wrappers import Limit
|
||||
|
||||
from langflow.api.utils import DbSession
|
||||
from langflow.api.v1.schemas import Token
|
||||
@ -12,10 +15,45 @@ from langflow.initial_setup.setup import get_or_create_default_folder
|
||||
from langflow.services.database.models.user.crud import get_user_by_id
|
||||
from langflow.services.database.models.user.model import UserRead
|
||||
from langflow.services.deps import get_auth_service, get_settings_service, get_variable_service
|
||||
from langflow.services.rate_limit import get_rate_limit_string
|
||||
|
||||
router = APIRouter(tags=["Login"])
|
||||
|
||||
|
||||
def get_limiter_from_app(request: Request):
|
||||
"""Get the rate limiter from app state (initialized after settings load)."""
|
||||
return request.app.state.limiter
|
||||
|
||||
|
||||
def check_rate_limit(request: Request) -> None:
|
||||
"""Check and enforce rate limit for the request.
|
||||
|
||||
Retrieves the limiter from app.state (initialized after settings load in main.py)
|
||||
and manually checks the rate limit using the limits library.
|
||||
|
||||
Raises:
|
||||
RateLimitExceeded: If the rate limit is exceeded
|
||||
"""
|
||||
limiter = get_limiter_from_app(request)
|
||||
|
||||
# Parse the rate limit string and check if limit is exceeded
|
||||
limit_item = parse(get_rate_limit_string())
|
||||
if not limiter._limiter.hit(limit_item, limiter._key_func(request)): # noqa: SLF001
|
||||
# Limit exceeded - raise RateLimitExceeded with proper wrapper
|
||||
limit_wrapper = Limit(
|
||||
limit=limit_item,
|
||||
key_func=limiter._key_func, # noqa: SLF001
|
||||
scope=None,
|
||||
per_method=False,
|
||||
methods=None,
|
||||
error_message=None,
|
||||
exempt_when=None,
|
||||
cost=1,
|
||||
override_defaults=False,
|
||||
)
|
||||
raise RateLimitExceeded(limit_wrapper)
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Session validation response."""
|
||||
|
||||
@ -31,6 +69,10 @@ async def login_to_get_access_token(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db: DbSession,
|
||||
):
|
||||
"""Login endpoint with rate limiting applied via app.state.limiter."""
|
||||
# Check rate limit (limiter is initialized in main.py after settings load)
|
||||
check_rate_limit(request)
|
||||
|
||||
auth_settings = get_settings_service().auth_settings
|
||||
try:
|
||||
auth = get_auth_service()
|
||||
|
||||
@ -757,6 +757,36 @@ def create_app():
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
# Add rate limit exception handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def rate_limit_exception_handler(request: Request, _exc: RateLimitExceeded):
|
||||
"""Handle rate limit exceeded errors with structured logging."""
|
||||
from langflow.services.rate_limit.service import get_limiter_key
|
||||
|
||||
# Default to 60 seconds for "/minute" window
|
||||
retry_after_seconds = "60"
|
||||
|
||||
client_ip = get_limiter_key(request)
|
||||
logger.warning(
|
||||
"Rate limit exceeded",
|
||||
auth_event="rate_limit_exceeded",
|
||||
client_ip=client_ip,
|
||||
path=request.url.path,
|
||||
method=request.method,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.TOO_MANY_REQUESTS,
|
||||
content={
|
||||
"detail": "Too many requests. Please try again later.",
|
||||
"retry_after": retry_after_seconds,
|
||||
},
|
||||
headers={
|
||||
"Retry-After": retry_after_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def exception_handler(_request: Request, exc: Exception):
|
||||
if isinstance(exc, HTTPException):
|
||||
@ -778,6 +808,12 @@ def create_app():
|
||||
|
||||
add_pagination(app)
|
||||
|
||||
# Add SlowAPI state to app for rate limiting
|
||||
from langflow.services.rate_limit import get_rate_limiter
|
||||
|
||||
limiter = get_rate_limiter()
|
||||
app.state.limiter = limiter
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
12
src/backend/base/langflow/services/rate_limit/__init__.py
Normal file
12
src/backend/base/langflow/services/rate_limit/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Rate limiting service for Langflow API endpoints.
|
||||
|
||||
This module provides rate limiting functionality using SlowAPI to protect
|
||||
against brute force attacks and abuse. It supports both IP-based and custom
|
||||
key-based rate limiting with configurable storage backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langflow.services.rate_limit.service import get_limiter_key, get_rate_limit_string, get_rate_limiter
|
||||
|
||||
__all__ = ["get_limiter_key", "get_rate_limit_string", "get_rate_limiter"]
|
||||
104
src/backend/base/langflow/services/rate_limit/service.py
Normal file
104
src/backend/base/langflow/services/rate_limit/service.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Rate limiting service implementation.
|
||||
|
||||
Provides a configured SlowAPI Limiter instance for rate limiting endpoints.
|
||||
Uses in-memory storage by default, with support for Redis in production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from langflow.services.deps import get_settings_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import Request
|
||||
|
||||
# Global limiter instance
|
||||
_limiter: Limiter | None = None
|
||||
|
||||
|
||||
def get_rate_limit_string() -> str:
|
||||
"""Get the rate limit string from settings.
|
||||
|
||||
Returns:
|
||||
str: Rate limit string in format "N/minute", or a very high limit if disabled
|
||||
"""
|
||||
settings = get_settings_service().settings
|
||||
if not settings.rate_limit_enabled:
|
||||
# Return a very high limit to effectively disable rate limiting
|
||||
return "10000/minute"
|
||||
return f"{settings.rate_limit_per_minute}/minute"
|
||||
|
||||
|
||||
def get_rate_limiter() -> Limiter:
|
||||
"""Get or create the global rate limiter instance.
|
||||
|
||||
Returns:
|
||||
Limiter: Configured SlowAPI Limiter instance
|
||||
"""
|
||||
global _limiter # noqa: PLW0603
|
||||
|
||||
if _limiter is None:
|
||||
settings = get_settings_service().settings
|
||||
|
||||
# Choose key function based on proxy configuration
|
||||
# When behind trusted proxies (load balancer, reverse proxy), use X-Forwarded-For
|
||||
# Otherwise use direct connection IP to prevent header spoofing
|
||||
key_func = get_client_ip if settings.rate_limit_trust_proxy else get_remote_address
|
||||
|
||||
_limiter = Limiter(
|
||||
key_func=key_func,
|
||||
storage_uri=settings.rate_limit_storage_uri,
|
||||
# Don't swallow errors - we want rate limit violations to raise exceptions
|
||||
swallow_errors=False,
|
||||
)
|
||||
|
||||
return _limiter
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP address from request, using rightmost X-Forwarded-For entry.
|
||||
|
||||
When behind a trusted proxy, the rightmost IP in X-Forwarded-For is the last hop
|
||||
before our server (the trusted proxy itself). The leftmost IP is client-supplied
|
||||
and can be spoofed.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
||||
Returns:
|
||||
str: Client IP address
|
||||
"""
|
||||
# Check X-Forwarded-For header first (for proxied requests)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# X-Forwarded-For format: "client, proxy1, proxy2"
|
||||
# Take the rightmost IP (last proxy before us)
|
||||
ips = [ip.strip() for ip in forwarded_for.split(",")]
|
||||
return ips[-1]
|
||||
|
||||
# Fall back to direct client IP
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_limiter_key(request: Request) -> str:
|
||||
"""Get the key that the limiter will actually use for this request.
|
||||
|
||||
This matches the limiter's key_func to ensure logging uses the same IP.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
||||
Returns:
|
||||
str: The IP address or key used for rate limiting
|
||||
"""
|
||||
settings = get_settings_service().settings
|
||||
if settings.rate_limit_trust_proxy:
|
||||
return get_client_ip(request)
|
||||
return get_remote_address(request)
|
||||
@ -19,6 +19,7 @@ maintainers = [
|
||||
dependencies = [
|
||||
"lfx~=0.5.0",
|
||||
"fastapi>=0.135.0,<1.0.0",
|
||||
"slowapi>=0.1.9,<1.0.0",
|
||||
"httpx[http2]>=0.27,<1.0.0",
|
||||
"aiofile>=3.9.0,<4.0.0",
|
||||
"uvicorn>=0.30.0,<1.0.0",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# we need to import tmpdir
|
||||
@ -41,6 +42,14 @@ from tests.api_keys import get_openai_api_key
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def disable_rate_limiting():
|
||||
"""Disable rate limiting for all tests to prevent 429 errors during test execution."""
|
||||
os.environ["LANGFLOW_RATE_LIMIT_ENABLED"] = "false"
|
||||
yield
|
||||
os.environ.pop("LANGFLOW_RATE_LIMIT_ENABLED", None)
|
||||
|
||||
|
||||
# TODO: Revert this to True once bb.functions[func].can_block_in("http/client.py", "_safe_read") is fixed
|
||||
@pytest.fixture(autouse=False)
|
||||
def blockbuster(request):
|
||||
|
||||
244
src/backend/tests/unit/test_login_rate_limiting.py
Normal file
244
src/backend/tests/unit/test_login_rate_limiting.py
Normal file
@ -0,0 +1,244 @@
|
||||
"""Tests for login endpoint rate limiting functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_rate_limiting(monkeypatch):
|
||||
"""Enable rate limiting for tests that need to verify rate limit behavior."""
|
||||
monkeypatch.setenv("LANGFLOW_RATE_LIMIT_ENABLED", "true")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def limiter_snapshot():
|
||||
"""Fixture to snapshot and restore the global limiter singleton."""
|
||||
import langflow.services.rate_limit.service as rate_limit_module
|
||||
|
||||
original_limiter = rate_limit_module._limiter
|
||||
# Force recreation of limiter for each test to ensure clean state
|
||||
rate_limit_module._limiter = None
|
||||
yield
|
||||
rate_limit_module._limiter = original_limiter
|
||||
|
||||
|
||||
class TestRateLimitService:
|
||||
"""Test suite for rate limit service configuration."""
|
||||
|
||||
def test_rate_limiter_is_configured(self, enable_rate_limiting): # noqa: ARG002
|
||||
"""Test that rate limiter singleton is properly configured."""
|
||||
from langflow.services.rate_limit import get_rate_limiter
|
||||
|
||||
limiter = get_rate_limiter()
|
||||
|
||||
assert limiter is not None
|
||||
assert limiter.enabled is True
|
||||
assert limiter._storage_uri == "memory://" # Default storage
|
||||
assert limiter._swallow_errors is False # Raise exceptions on rate limit
|
||||
|
||||
def test_rate_limiter_is_singleton(self, enable_rate_limiting): # noqa: ARG002
|
||||
"""Test that get_rate_limiter returns the same instance."""
|
||||
from langflow.services.rate_limit import get_rate_limiter
|
||||
|
||||
limiter1 = get_rate_limiter()
|
||||
limiter2 = get_rate_limiter()
|
||||
|
||||
assert limiter1 is limiter2
|
||||
|
||||
def test_rate_limit_string_default(self, enable_rate_limiting): # noqa: ARG002
|
||||
"""Test that default rate limit string is correct."""
|
||||
from langflow.services.rate_limit.service import get_rate_limit_string
|
||||
|
||||
rate_limit = get_rate_limit_string()
|
||||
|
||||
assert rate_limit == "5/minute"
|
||||
|
||||
def test_rate_limiter_uses_remote_address_by_default(self, enable_rate_limiting): # noqa: ARG002
|
||||
"""Test that rate limiter uses get_remote_address when trust_proxy is false."""
|
||||
from langflow.services.rate_limit import get_rate_limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = get_rate_limiter()
|
||||
|
||||
# Default should use get_remote_address (not trust proxy)
|
||||
assert limiter._key_func == get_remote_address
|
||||
|
||||
def test_rate_limiter_uses_client_ip_when_trust_proxy_enabled(
|
||||
self,
|
||||
enable_rate_limiting, # noqa: ARG002
|
||||
limiter_snapshot, # noqa: ARG002
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that rate limiter uses get_client_ip when trust_proxy is true."""
|
||||
# Mock settings to enable trust_proxy
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from langflow.services.rate_limit.service import get_client_ip
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.rate_limit_trust_proxy = True
|
||||
mock_settings.rate_limit_storage_uri = "memory://"
|
||||
|
||||
mock_settings_service = MagicMock()
|
||||
mock_settings_service.settings = mock_settings
|
||||
|
||||
monkeypatch.setattr("langflow.services.rate_limit.service.get_settings_service", lambda: mock_settings_service)
|
||||
|
||||
from langflow.services.rate_limit import get_rate_limiter
|
||||
|
||||
limiter = get_rate_limiter()
|
||||
|
||||
# Should use get_client_ip when trust_proxy is enabled
|
||||
assert limiter._key_func == get_client_ip
|
||||
|
||||
|
||||
class TestIPExtraction:
|
||||
"""Test suite for IP address extraction logic."""
|
||||
|
||||
def test_get_client_ip_from_x_forwarded_for_single(self):
|
||||
"""Test IP extraction from X-Forwarded-For with single IP."""
|
||||
from langflow.services.rate_limit.service import get_client_ip
|
||||
|
||||
request = Mock()
|
||||
request.headers = {"X-Forwarded-For": "203.0.113.1"}
|
||||
request.client = Mock(host="127.0.0.1")
|
||||
|
||||
ip = get_client_ip(request)
|
||||
|
||||
assert ip == "203.0.113.1"
|
||||
|
||||
def test_get_client_ip_from_x_forwarded_for_chain_uses_rightmost(self):
|
||||
"""Test IP extraction from X-Forwarded-For uses rightmost IP (trusted proxy)."""
|
||||
from langflow.services.rate_limit.service import get_client_ip
|
||||
|
||||
request = Mock()
|
||||
request.headers = {"X-Forwarded-For": "203.0.113.1, 198.51.100.1, 192.0.2.1"}
|
||||
request.client = Mock(host="127.0.0.1")
|
||||
|
||||
ip = get_client_ip(request)
|
||||
|
||||
# Should use rightmost IP (the trusted proxy before us)
|
||||
assert ip == "192.0.2.1"
|
||||
|
||||
def test_get_client_ip_from_x_forwarded_for_with_spaces(self):
|
||||
"""Test IP extraction handles spaces in X-Forwarded-For."""
|
||||
from langflow.services.rate_limit.service import get_client_ip
|
||||
|
||||
request = Mock()
|
||||
request.headers = {"X-Forwarded-For": " 203.0.113.1 , 198.51.100.1 "}
|
||||
request.client = Mock(host="127.0.0.1")
|
||||
|
||||
ip = get_client_ip(request)
|
||||
|
||||
# Should use rightmost IP, stripped of whitespace
|
||||
assert ip == "198.51.100.1"
|
||||
|
||||
def test_get_client_ip_from_direct_connection(self):
|
||||
"""Test IP extraction from direct client connection (no proxy)."""
|
||||
from langflow.services.rate_limit.service import get_client_ip
|
||||
|
||||
request = Mock()
|
||||
request.headers = {}
|
||||
request.client = Mock(host="192.168.1.100")
|
||||
|
||||
ip = get_client_ip(request)
|
||||
|
||||
assert ip == "192.168.1.100"
|
||||
|
||||
def test_get_client_ip_fallback_to_unknown(self):
|
||||
"""Test IP extraction returns 'unknown' when no client info available."""
|
||||
from langflow.services.rate_limit.service import get_client_ip
|
||||
|
||||
request = Mock()
|
||||
request.headers = {}
|
||||
request.client = None
|
||||
|
||||
ip = get_client_ip(request)
|
||||
|
||||
assert ip == "unknown"
|
||||
|
||||
def test_get_client_ip_prefers_x_forwarded_for(self):
|
||||
"""Test that X-Forwarded-For takes precedence over direct client IP."""
|
||||
from langflow.services.rate_limit.service import get_client_ip
|
||||
|
||||
request = Mock()
|
||||
request.headers = {"X-Forwarded-For": "203.0.113.1"}
|
||||
request.client = Mock(host="127.0.0.1")
|
||||
|
||||
ip = get_client_ip(request)
|
||||
|
||||
# Should use X-Forwarded-For, not client.host
|
||||
assert ip == "203.0.113.1"
|
||||
assert ip != "127.0.0.1"
|
||||
|
||||
|
||||
class TestRateLimitIntegration:
|
||||
"""Integration tests verifying rate limiting is applied to login endpoint."""
|
||||
|
||||
def test_rate_limit_enforcement_returns_429(self, enable_rate_limiting, limiter_snapshot, active_user): # noqa: ARG002
|
||||
"""Test that exceeding rate limit returns 429 status code.
|
||||
|
||||
Uses TestClient (synchronous) instead of AsyncClient to properly test SlowAPI rate limiting.
|
||||
Requires limiter_snapshot fixture to ensure clean state and avoid interference from other tests.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from langflow.main import create_app
|
||||
|
||||
# Create a fresh app instance for this test
|
||||
app = create_app()
|
||||
sync_client = TestClient(app)
|
||||
|
||||
# Make 5 requests (the default limit)
|
||||
status_codes = []
|
||||
for i in range(5):
|
||||
response = sync_client.post(
|
||||
"/api/v1/login",
|
||||
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
|
||||
)
|
||||
status_codes.append(response.status_code)
|
||||
assert response.status_code == 200, f"Request {i + 1} should succeed, got {response.status_code}"
|
||||
|
||||
# 6th request should be rate limited
|
||||
response = sync_client.post(
|
||||
"/api/v1/login",
|
||||
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
|
||||
)
|
||||
status_codes.append(response.status_code)
|
||||
|
||||
assert response.status_code == 429, f"Expected 429, got {response.status_code}. All codes: {status_codes}"
|
||||
response_detail = response.json()["detail"].lower()
|
||||
assert "too many requests" in response_detail or "rate limit" in response_detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_endpoint_has_rate_limiter_applied(self, enable_rate_limiting): # noqa: ARG002
|
||||
"""Test that the login endpoint has rate limiting applied via app.state.limiter."""
|
||||
from langflow.api.v1.login import get_limiter_from_app, login_to_get_access_token
|
||||
from langflow.main import create_app
|
||||
|
||||
# Create app to ensure limiter is attached to app.state
|
||||
app = create_app()
|
||||
|
||||
# Verify limiter is attached to app.state
|
||||
assert hasattr(app.state, "limiter")
|
||||
assert app.state.limiter is not None
|
||||
assert app.state.limiter.enabled is True
|
||||
|
||||
# Verify the endpoint function exists
|
||||
assert login_to_get_access_token is not None
|
||||
# Verify get_limiter_from_app helper exists
|
||||
assert get_limiter_from_app is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_login_within_reasonable_limit(self, enable_rate_limiting, client, active_user): # noqa: ARG002
|
||||
"""Test that a single login request succeeds (well within any rate limit)."""
|
||||
response = await client.post(
|
||||
"/api/v1/login",
|
||||
data={"username": active_user.username, "password": "testpassword"}, # pragma: allowlist secret
|
||||
headers={"X-Forwarded-For": "10.0.0.1"}, # Unique IP to avoid conflicts
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
@ -255,6 +255,16 @@ class Settings(BaseSettings):
|
||||
redis_url: str | None = None
|
||||
redis_cache_expire: int = 3600
|
||||
|
||||
# Rate Limiting
|
||||
rate_limit_enabled: bool = True
|
||||
"""Enable rate limiting for login endpoint. Set to False to disable (useful for testing)."""
|
||||
rate_limit_per_minute: int = 5
|
||||
"""Number of login attempts allowed per minute per IP."""
|
||||
rate_limit_storage_uri: str = "memory://"
|
||||
"""Storage backend for rate limiting. Use 'memory://' for single-server or 'redis://host:port' for multi-server."""
|
||||
rate_limit_trust_proxy: bool = False
|
||||
"""Trust X-Forwarded-For header when behind a reverse proxy. Only enable when behind a trusted proxy."""
|
||||
|
||||
# Job Queue
|
||||
job_queue_type: Literal["asyncio", "redis"] = "asyncio"
|
||||
"""The job queue backend. Use 'redis' for multi-worker deployments to solve cross-worker JobQueueNotFoundError."""
|
||||
|
||||
28
uv.lock
generated
28
uv.lock
generated
@ -7675,6 +7675,7 @@ dependencies = [
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "sentry-sdk", extra = ["fastapi", "loguru"] },
|
||||
{ name = "setuptools" },
|
||||
{ name = "slowapi" },
|
||||
{ name = "spider-client" },
|
||||
{ name = "sqlalchemy", extra = ["aiosqlite"] },
|
||||
{ name = "sqlmodel" },
|
||||
@ -8595,6 +8596,7 @@ requires-dist = [
|
||||
{ name = "sentence-transformers", marker = "extra == 'sentence-transformers'", specifier = ">=2.3.1" },
|
||||
{ name = "sentry-sdk", extras = ["fastapi", "loguru"], specifier = ">=2.5.1,<3.0.0" },
|
||||
{ name = "setuptools", specifier = ">=80.0.0,<81.0.0" },
|
||||
{ name = "slowapi", specifier = ">=0.1.9,<1.0.0" },
|
||||
{ name = "smolagents", marker = "extra == 'smolagents'", specifier = ">=1.8.0" },
|
||||
{ name = "spider-client", specifier = ">=0.0.27,<1.0.0" },
|
||||
{ name = "spider-client", marker = "extra == 'spider'", specifier = ">=0.0.27,<1.0.0" },
|
||||
@ -9345,6 +9347,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "limits"
|
||||
version = "5.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "packaging" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "line-profiler"
|
||||
version = "5.0.2"
|
||||
@ -17127,6 +17143,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slowapi"
|
||||
version = "0.1.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "limits" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smmap"
|
||||
version = "5.0.3"
|
||||
|
||||
Reference in New Issue
Block a user