Skip to content
2 changes: 1 addition & 1 deletion backend/examples/kaggle_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ def invoke(self, input_str):
import operator as op
operators = {ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul,
ast.Div: op.truediv, ast.Pow: op.pow,
ast.UnaryOp: op.neg}
ast.USub: op.neg, ast.UAdd: op.pos}

def _eval(node):
if isinstance(node, ast.Constant):
Expand Down
16 changes: 7 additions & 9 deletions backend/scripts/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Any, Dict, List

from dotenv import load_dotenv
from langchain_core.messages import HumanMessage

# Load env vars before importing evaluators or agent components
load_dotenv()
Expand Down Expand Up @@ -60,13 +61,13 @@

results = []

for item in questions:
for idx, item in enumerate(questions):
question = item.get("question")
if not question:
logger.warning(f"Skipping malformed item missing 'question': {item}")
continue
expected_topics = item.get("expected_topics", [])
logger.info(f"Running benchmark for: {question}")
logger.info(f"Running benchmark for question index: {idx}")

try:
# Invoke agent
Expand All @@ -76,7 +77,7 @@
# Increase recursion limit to handle multi-step research plans (default is 25)
# Disable planning confirmation to allow automated execution
response = await graph.ainvoke(
{"messages": [("user", question)]},
{"messages": [HumanMessage(content=question)]},
config={
"recursion_limit": 100,
"configurable": {"require_planning_confirmation": False},
Expand Down Expand Up @@ -132,14 +133,11 @@
results.append(result_entry)

logger.info(
"Result for '%s': Q=%s, G=%s",
question,
result_entry["quality_score"],
result_entry["groundedness_score"],
)
f"Result for question index {idx}: Q={result_entry['quality_score']}, G={result_entry['groundedness_score']}"
) # NOSONAR

except Exception as e:
logger.error(f"Agent failed for '{question}'", exc_info=True)
logger.error(f"Agent failed for question index {idx}: {e}", exc_info=True)

Check failure on line 140 in backend/scripts/benchmark.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "logging.exception()" instead.

See more on https://sonarcloud.io/project/issues?id=MasumRab_gemini-fullstack-langgraph-quickstart&issues=AZ-0hbbdPjA6nTB9tKAe&open=AZ-0hbbdPjA6nTB9tKAe&pullRequest=347
continue

# Report Generation
Expand Down
1 change: 0 additions & 1 deletion backend/scripts/visualize_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import matplotlib.pyplot as plt
import numpy as np
import pkg_resources
import scipy.cluster.hierarchy as sch

# Set up paths
Expand Down
2 changes: 1 addition & 1 deletion backend/src/agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,4 @@ def draw_graph_png():
return graph.get_graph().draw_mermaid_png()


# Removed stale items for visualization as draw_graph_png is now implemented.
# Removed stale TODOs for visualization as draw_graph_png is now implemented.
94 changes: 72 additions & 22 deletions backend/src/agent/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,39 +25,78 @@
# Format: comma-separated IPs or CIDR ranges, e.g., "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
TRUSTED_PROXIES_ENV = os.getenv("TRUSTED_PROXIES", "")
TRUSTED_PROXIES: Set[str] = set()
TRUSTED_PROXY_NETWORKS: list = [] # Pre-parsed networks for CIDR ranges
TRUSTED_PROXY_ADDRESSES: set = set() # Pre-parsed single IP addresses
if TRUSTED_PROXIES_ENV:
TRUSTED_PROXIES = set(
ip.strip() for ip in TRUSTED_PROXIES_ENV.split(",") if ip.strip()
)
# Pre-parse into networks and addresses for performance
for trusted in TRUSTED_PROXIES:
trusted = trusted.strip()
if not trusted:
continue
if "/" in trusted:
# CIDR range
try:
network = ipaddress.ip_network(trusted, strict=False)
TRUSTED_PROXY_NETWORKS.append(network)
except ValueError:
logger.warning(f"Invalid CIDR in TRUSTED_PROXIES: {trusted}")
else:
# Single IP address
try:
addr = ipaddress.ip_address(trusted)
TRUSTED_PROXY_ADDRESSES.add(addr)
except ValueError:
logger.warning(f"Invalid IP in TRUSTED_PROXIES: {trusted}")


def _is_ip_in_trusted_proxies(ip: str) -> bool:
def _is_ip_in_trusted_proxies(

Check failure on line 55 in backend/src/agent/security.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=MasumRab_gemini-fullstack-langgraph-quickstart&issues=AZ-0f-eWZ3uIvCCdPT6-&open=AZ-0f-eWZ3uIvCCdPT6-&pullRequest=347
ip: str, trusted_proxies: Set[str] | None = None
) -> bool: # NOSONAR
"""Check if an IP address is in the trusted proxies set.

Supports both direct IP matching and CIDR range matching.
Uses pre-parsed networks and addresses for better performance.
"""
if not TRUSTED_PROXIES:
return False

try:
ip_obj = ipaddress.ip_address(ip.strip())
for trusted in TRUSTED_PROXIES:
if trusted_proxies is None:
# Use pre-parsed collections
trusted_addrs = TRUSTED_PROXY_ADDRESSES
trusted_nets = TRUSTED_PROXY_NETWORKS
else:
# For dynamic trusted_proxies parameter, parse on-demand
trusted_addrs = set()
trusted_nets = []
for trusted in trusted_proxies:
trusted = trusted.strip()
if not trusted:
continue
if "/" in trusted:
# CIDR range
try:
network = ipaddress.ip_network(trusted, strict=False)
if ip_obj in network:
return True
trusted_nets.append(network)
except ValueError:
continue
else:
# Direct IP match
try:
if ip_obj == ipaddress.ip_address(trusted):
return True
addr = ipaddress.ip_address(trusted)
trusted_addrs.add(addr)
except ValueError:
continue

if not trusted_addrs and not trusted_nets:
return False

try:
ip_obj = ipaddress.ip_address(ip.strip())
# Check direct IP match first
if ip_obj in trusted_addrs:
return True
# Check CIDR ranges
for network in trusted_nets:
if ip_obj in network:
return True
return False
except ValueError:
return False
Expand All @@ -66,6 +105,7 @@
def extract_client_ip_from_forwarded(
forwarded: str,
trusted_proxy_count: int | None = None,
trusted_proxies: Set[str] | None = None,
fallback_ip: str | None = None,
Comment on lines +107 to 109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the helper docs to match the new signature and indexing rule.

trusted_proxies was added to the signature, but the docstring still omits it, and the method overview above still describes ips[-(trusted_proxy_count + 1)] while this branch now uses ips[-trusted_proxy_count]. In this code path, stale docs make TRUSTED_PROXY_COUNT easy to misread and misconfigure.

Also applies to: 139-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/agent/security.py` around lines 73 - 75, Update the helper
docstrings to match the new function signature and the corrected indexing rule:
add documentation for the new trusted_proxies parameter alongside
trusted_proxy_count and fallback_ip, and change any description that says
ips[-(trusted_proxy_count + 1)] to the correct ips[-trusted_proxy_count]
indexing. Make these edits in the docstring immediately above the function that
declares trusted_proxy_count, trusted_proxies, and fallback_ip and in the other
docstring block that references TRUSTED_PROXY_COUNT (the second occurrence
around the later helper description), ensuring the parameter list and examples
reflect the new behavior.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules
Verify each finding against the current code and only fix it if needed.

In @backend/src/agent/security.py around lines 73 - 75, Update the helper
docstrings to match the new function signature and the corrected indexing rule:
add documentation for the new trusted_proxies parameter alongside
trusted_proxy_count and fallback_ip, and change any description that says
ips[-(trusted_proxy_count + 1)] to the correct ips[-trusted_proxy_count]
indexing. Make these edits in the docstring immediately above the function that
declares trusted_proxy_count, trusted_proxies, and fallback_ip and in the other
docstring block that references TRUSTED_PROXY_COUNT (the second occurrence
around the later helper description), ensuring the parameter list and examples
reflect the new behavior.

) -> str | None:
"""Extract the real client IP from X-Forwarded-For header using trust-bound extraction.
Expand All @@ -84,14 +124,15 @@
Args:
forwarded: The X-Forwarded-For header value.
trusted_proxy_count: Number of trusted proxies between client and server.
Uses TRUSTED_PROXY_COUNT global if not provided.
trusted_proxies: Set of trusted proxy IP addresses. Uses TRUSTED_PROXIES
global if not provided. When provided, enables right-to-left skipping
of trusted proxy IPs.
fallback_ip: IP to return if no valid candidate is found.

Returns:
The extracted client IP, or fallback_ip if no valid candidate found.
"""
if trusted_proxy_count is None:
trusted_proxy_count = TRUSTED_PROXY_COUNT

if not forwarded:
return fallback_ip

Expand All @@ -115,10 +156,12 @@
return fallback_ip

# Method 1: Use trusted proxies list if available (more flexible)
if TRUSTED_PROXIES:
_tp = trusted_proxies if trusted_proxies is not None else TRUSTED_PROXIES

if _tp:
# Iterate from right to left, skip trusted proxies
for ip in reversed(ips):
if not _is_ip_in_trusted_proxies(ip):
if not _is_ip_in_trusted_proxies(ip, _tp):
return ip
# All IPs are trusted proxies, return the leftmost (original client)
# This shouldn't happen in normal operation
Expand All @@ -128,6 +171,9 @@
return ips[0] if ips else fallback_ip

# Method 2: Use trusted proxy count
if trusted_proxy_count is None:
trusted_proxy_count = TRUSTED_PROXY_COUNT

if trusted_proxy_count > 0:
# Pick ips[-(trusted_proxy_count + 1)]
# For example, if trusted_proxy_count=1 and ips=[client, proxy1],
Expand All @@ -136,12 +182,13 @@
if abs(idx) <= len(ips):
return ips[idx]
else:
# Not enough IPs in the chain, return leftmost
# Not enough IPs in the chain, return fallback for security
# Don't use header-controlled ips[0] as it could be spoofed
logger.warning(
f"Not enough IPs in X-Forwarded-For for trusted_proxy_count={trusted_proxy_count}, "
f"using leftmost IP"
f"using fallback IP"
)
return ips[0] if ips else fallback_ip
return fallback_ip

# No trusted proxies configured - return fallback for safety
# This prevents IP spoofing when trust_proxy_headers is True but no proxies are configured
Expand Down Expand Up @@ -275,7 +322,10 @@
# 🛡️ Sentinel: Use trust-bound IP extraction instead of naive ips[0]
# The leftmost IP is attacker-controllable; we must use trust-bound extraction.
client_ip = extract_client_ip_from_forwarded(
forwarded=forwarded, fallback_ip=fallback_ip
forwarded=forwarded,
trusted_proxy_count=TRUSTED_PROXY_COUNT,
trusted_proxies=TRUSTED_PROXIES,
fallback_ip=fallback_ip,
)
if client_ip is None:
client_ip = fallback_ip
Expand Down
4 changes: 0 additions & 4 deletions backend/src/config/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,6 @@ class AppConfig:
allowed_hosts: Tuple[str, ...] = tuple(
filter(None, os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(","))
)
trust_proxy_headers: bool = (
os.getenv("TRUST_PROXY_HEADERS", "false").lower() == "true"
)

# Model Selection
model_planning: str = os.getenv("MODEL_PLANNING", "gemma-3-27b-it")
model_validation: str = os.getenv("MODEL_VALIDATION", "gemma-3-27b-it")
Expand Down
60 changes: 31 additions & 29 deletions backend/tests/agent/test_rate_limiter_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ def test_rate_limiter_integration():


@pytest.mark.asyncio
async def test_rate_limiter_proxy_logic(monkeypatch):
import agent.security

@patch("agent.security.TRUSTED_PROXIES", set())
@patch("agent.security.TRUSTED_PROXY_COUNT", 1)
async def test_rate_limiter_proxy_logic():
"""Unit test for RateLimitMiddleware proxy logic."""

# Mock App
Expand All @@ -41,7 +41,6 @@ async def mock_app(scope, receive, send):
# Create middleware instance with low limit (2 per minute)
# We use a distinct path prefix to ensure we hit the logic
# 🛡️ Sentinel: Explicitly enable trust_proxy_headers for this test as we want to test X-Forwarded-For logic
monkeypatch.setattr(agent.security, "TRUSTED_PROXY_COUNT", 1)
middleware = RateLimitMiddleware(
mock_app,
limit=2,
Expand Down Expand Up @@ -75,50 +74,49 @@ async def mock_receive():
return sent_messages

# Scenario:
# Client A (Real IP: 1.2.3.4) -> Proxy (IP: 10.0.0.1) -> App
# Client B (Real IP: 5.6.7.8) -> Proxy (IP: 10.0.0.1) -> App
# Client A (Real IP: 192.0.2.1) -> Proxy (IP: 192.0.2.100) -> App
# Client B (Real IP: 198.51.100.1) -> Proxy (IP: 192.0.2.100) -> App

# 1. Client A sends requests
# The trusted proxy (Render) appends the REAL client IP to the end of X-Forwarded-For.
# So if Client A is 1.2.3.4, the header seen by app is "..., 1.2.3.4"
header_a = "1.2.3.4"
# So if Client A is 192.0.2.1, the header seen by app is "..., 192.0.2.1"
header_a = "192.0.2.1"

await call_middleware("/protected", "10.0.0.1", header_a)
await call_middleware("/protected", "10.0.0.1", header_a)
await call_middleware("/protected", "192.0.2.100", header_a)
await call_middleware("/protected", "192.0.2.100", header_a)

# 2. Client B sends requests
header_b = "5.6.7.8"
header_b = "198.51.100.1"

await call_middleware("/protected", "10.0.0.1", header_b)
await call_middleware("/protected", "192.0.2.100", header_b)

# 3. Verify Internal State
# We verify that the middleware tracks the IPs from X-Forwarded-For (Client A/B)
# and ignores the direct connection IP (10.0.0.1 - the proxy).
# and ignores the direct connection IP (192.0.2.100 - the proxy).

print(f"\nMiddleware State: {middleware.requests}")

assert "1.2.3.4" in middleware.requests
assert len(middleware.requests["1.2.3.4"]) == 2
assert "192.0.2.1" in middleware.requests
assert len(middleware.requests["192.0.2.1"]) == 2

assert "5.6.7.8" in middleware.requests
assert len(middleware.requests["5.6.7.8"]) == 1
assert "198.51.100.1" in middleware.requests
assert len(middleware.requests["198.51.100.1"]) == 1

# "10.0.0.1" (Proxy IP) should NOT be tracked as a client
assert "10.0.0.1" not in middleware.requests
# "192.0.2.100" (Proxy IP) should NOT be tracked as a client
assert "192.0.2.100" not in middleware.requests


@pytest.mark.asyncio
async def test_rate_limiter_truncation(monkeypatch):
import agent.security

@patch("agent.security.TRUSTED_PROXIES", set())
@patch("agent.security.TRUSTED_PROXY_COUNT", 1)
async def test_rate_limiter_truncation():
"""Test that extremely long headers are truncated to prevent memory exhaustion."""

async def mock_app(scope, receive, send):
response = PlainTextResponse("OK")
await response(scope, receive, send)

# 🛡️ Sentinel: Enable proxy trust to test header parsing
monkeypatch.setattr(agent.security, "TRUSTED_PROXY_COUNT", 1)
middleware = RateLimitMiddleware(
mock_app,
limit=10,
Expand All @@ -127,7 +125,10 @@ async def mock_app(scope, receive, send):
trust_proxy_headers=True,
)

long_ip = "1.2.3.4" + "a" * 1000 # Very long string
# Use a syntactically valid but very long IP to test truncation
# This ensures extract_client_ip_from_forwarded doesn't reject it as invalid
# and the middleware actually truncates it at line 334
long_ip = "192.0.2." + "1," + "192.0.2." * 100 # Valid IP pattern, very long
headers = [(b"x-forwarded-for", long_ip.encode())]

scope = {
Expand All @@ -138,15 +139,16 @@ async def mock_app(scope, receive, send):
}

async def mock_send(message):
pass
return None # NOSONAR

async def mock_receive():
return {"type": "http.request"}
return {"type": "http.request"} # NOSONAR

await middleware(scope, mock_receive, mock_send)

# Verify the key in requests is truncated
# Verify the key in requests is truncated to 100 chars
keys = list(middleware.requests.keys())
assert len(keys) == 1
# Now that we sanitize invalid IPs to "unknown", it won't match the truncated string
assert keys[0] == "127.0.0.1"
# The client_ip should be extracted from X-Forwarded-For and then truncated to 100 chars
# Verify truncation worked - key should be <= 100 chars
assert len(keys[0]) <= 100, f"Client key {len(keys[0])} chars exceeds 100 char limit"
1 change: 0 additions & 1 deletion backend/tests/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
DEFAULT_QUERY_MODEL,
DEFAULT_REFLECTION_MODEL,
GEMINI_PRO,
TEST_MODEL,
)


Expand Down
Loading
Loading