Skip to content

Commit d00fa46

Browse files
RafaelPoclaude
andauthored
Security hardening: auth replay, encryption guards, rate limits, CORS (#209)
* Security hardening: auth replay, encryption guards, rate limits, CORS, error sanitization (#209) Address 18 security audit findings (7 HIGH, 8 MEDIUM, 3 LOW) across the MCP HTTP server, plus test and observability improvements. HIGH: - H1: Fail-closed encryption — encrypt/decrypt raise RuntimeError in HTTP mode when UPLOAD_SECRET is missing. Required regardless of --no-auth. - H2: ALLOW_NO_AUTH strict check — only "1" disables auth. - H3: Redis password required in authenticated HTTP mode (hard error). - H4: HTTPS-only URL validation on mcp_server_url and supabase_url. - H5: Referrer-Policy no-referrer meta tag in both widget templates. - H6: Auth code and refresh token re-storage uses SET NX to prevent concurrent overwrites. - H7: OAuth state consumed atomically in handle_start, re-stored to narrow replay window. MEDIUM: - M1: Security events (revoked tokens, failed polls, rate limits) at WARNING. - M2: Stack traces suppressed in error responses; generic messages returned. - M3: app.openLink() calls guarded with https scheme check. - M4: rel="noopener noreferrer" on linkified anchors in results widget. - M5: Removed in-memory rate limit fallback — Redis is a hard dependency. - M6: Rate limit TTL always refreshed (removed nx=True on EXPIRE). - M7: CORS wildcard origin for widget endpoints (auth via Bearer tokens). - M8: Content-Type allowlist on upload endpoint (415 for unsupported types). LOW: - L1: Per-user upload rate limiting via atomic Redis pipeline. - L4: Remote Redis without SSL fails in HTTP mode (warns in stdio). Additional: - Upload tool fails early when client has no API token. - Direct path_params lookup for state in handle_start. - E2e integration test: swap results tool to HTTP variant in _http_state. - Fernet cache cleared in test override_settings for correct encryption. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix upload URL consumed before rate-limit check Peek upload metadata (non-destructive GET) during validation, then atomically pop (GETDEL) only after content-type, token, and rate-limit checks pass. This makes 429/415/403 responses retryable without burning the one-time upload URL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de3dcef commit d00fa46

24 files changed

Lines changed: 616 additions & 184 deletions

everyrow-mcp/src/everyrow_mcp/auth.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,12 @@ async def verify_token(self, token: str) -> AccessToken | None:
9999
payload = self._decode_jwt(token, signing_key)
100100

101101
if await self._is_revoked(token):
102-
logger.debug("Token is revoked")
102+
logger.warning("Revoked token presented")
103103
return None
104104

105105
sub = payload.get("sub")
106106
if not sub:
107-
logger.debug("JWT missing required 'sub' claim")
107+
logger.warning("JWT missing required 'sub' claim")
108108
return None
109109
return AccessToken(
110110
token=token,
@@ -116,7 +116,7 @@ async def verify_token(self, token: str) -> AccessToken | None:
116116
logger.warning("JWKS fetch timed out (10s)")
117117
return None
118118
except pyjwt.PyJWTError:
119-
logger.debug("JWT verification failed")
119+
logger.warning("JWT verification failed")
120120
return None
121121

122122

@@ -212,7 +212,7 @@ async def _check_rate_limit(self, action: str, client_ip: str) -> None:
212212
rl_key = build_key("ratelimit", action, client_ip)
213213
async with self._redis.pipeline() as pipe:
214214
pipe.incr(rl_key)
215-
pipe.expire(rl_key, settings.registration_rate_window, nx=True)
215+
pipe.expire(rl_key, settings.registration_rate_window)
216216
count, _ = await pipe.execute()
217217
if count > settings.registration_rate_limit:
218218
raise ValueError(f"{action.title()} rate limit exceeded")
@@ -232,6 +232,7 @@ async def register_client(self, client_info: OAuthClientInformationFull) -> None
232232
time=settings.client_registration_ttl,
233233
value=client_info.model_dump_json(),
234234
)
235+
logger.info("Registered new OAuth client client_id=%s", client_info.client_id)
235236

236237
@staticmethod
237238
def _supabase_redirect_url(supabase_verifier: str) -> str:
@@ -361,11 +362,16 @@ async def authorize(
361362
return f"{settings.mcp_server_url}/auth/start/{state}"
362363

363364
async def handle_start(self, request: Request) -> RedirectResponse:
365+
state = request.path_params["state"]
364366
pending = await self._validate_auth_request(
365-
request, "start", request.path_params.get("state")
367+
request, "start", state, consume=True
368+
)
369+
# Re-store so the callback can still find it
370+
await self._redis.setex(
371+
name=build_key("pending", state),
372+
time=settings.pending_auth_ttl,
373+
value=pending.model_dump_json(),
366374
)
367-
368-
state = request.path_params.get("state", "")
369375
response = RedirectResponse(url=pending.supabase_redirect_url, status_code=302)
370376
response.set_cookie(
371377
key="__Host-mcp_auth_state",
@@ -434,9 +440,9 @@ async def load_authorization_code(
434440
if code_obj.expires_at and code_obj.expires_at < time.time():
435441
return None
436442
if code_obj.client_id != client.client_id:
437-
# Re-store so the legitimate client can still use it.
443+
# Re-store so the legitimate client can still use it (NX prevents overwrite).
438444
remaining = max(1, int((code_obj.expires_at or 0) - time.time()))
439-
await self._redis.setex(key, remaining, code_data_encrypted)
445+
await self._redis.set(key, code_data_encrypted, ex=remaining, nx=True)
440446
return None
441447
return code_obj
442448

@@ -476,6 +482,7 @@ async def exchange_authorization_code(
476482
authorization_code: EveryRowAuthorizationCode,
477483
) -> OAuthToken:
478484
assert client.client_id is not None
485+
logger.info("Token exchange successful user=%s", authorization_code.client_id)
479486
return await self._issue_token_response(
480487
access_token=authorization_code.supabase_access_token,
481488
client_id=client.client_id,
@@ -499,8 +506,10 @@ async def load_refresh_token(
499506
return None
500507
rt = EveryRowRefreshToken.model_validate_json(decrypt_value(data_encrypted))
501508
if rt.client_id != client.client_id:
502-
# Re-store so the legitimate client can still use it.
503-
await self._redis.setex(key, settings.refresh_token_ttl, data_encrypted)
509+
# Re-store so the legitimate client can still use it (NX prevents overwrite).
510+
await self._redis.set(
511+
key, data_encrypted, ex=settings.refresh_token_ttl, nx=True
512+
)
504513
return None
505514
return rt
506515

@@ -524,6 +533,7 @@ async def exchange_refresh_token(
524533
)
525534
raise
526535
assert client.client_id is not None
536+
logger.info("Token refresh successful user=%s", client.client_id)
527537
return await self._issue_token_response(
528538
access_token=supa_tokens.access_token,
529539
client_id=client.client_id,

everyrow-mcp/src/everyrow_mcp/config.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from __future__ import annotations
22

3+
import logging
34
from functools import lru_cache
5+
from urllib.parse import urlparse
46

5-
from pydantic import Field, PositiveInt, field_validator
7+
from pydantic import Field, PositiveInt, field_validator, model_validator
68
from pydantic_settings import BaseSettings, SettingsConfigDict
79

10+
logger = logging.getLogger(__name__)
11+
812

913
class Settings(BaseSettings):
1014
model_config = SettingsConfigDict(extra="ignore")
@@ -108,6 +112,15 @@ class Settings(BaseSettings):
108112
description="Maximum response size when fetching CSV from a URL (50 MB).",
109113
)
110114

115+
upload_rate_limit: PositiveInt = Field(
116+
default=20,
117+
description="Max uploads per user per rate window",
118+
)
119+
upload_rate_window: PositiveInt = Field(
120+
default=3600,
121+
description="Upload rate limit sliding window in seconds (1 hour)",
122+
)
123+
111124
everyrow_api_key: str | None = Field(default=None, repr=False)
112125

113126
@property
@@ -120,8 +133,34 @@ def is_stdio(self) -> bool:
120133

121134
@field_validator("mcp_server_url", "supabase_url")
122135
@classmethod
123-
def _strip_url_slashes(cls, v: str) -> str:
124-
return v.rstrip("/")
136+
def _validate_url(cls, v: str) -> str:
137+
v = v.rstrip("/")
138+
if not v:
139+
return v
140+
parsed = urlparse(v)
141+
host = (parsed.hostname or "").lower()
142+
is_local = host in ("localhost", "127.0.0.1", "::1")
143+
if not is_local and parsed.scheme != "https":
144+
raise ValueError(
145+
f"Non-localhost URLs must use https:// (got {parsed.scheme}://)"
146+
)
147+
return v
148+
149+
@model_validator(mode="after")
150+
def _require_redis_ssl_for_remote(self) -> Settings:
151+
host = (self.redis_host or "").lower()
152+
is_local = host in ("localhost", "127.0.0.1", "::1", "")
153+
if not is_local and not self.redis_ssl:
154+
if self.is_http:
155+
raise ValueError(
156+
f"Redis host {self.redis_host} is remote but redis_ssl=False. "
157+
"Enable redis_ssl for non-localhost Redis in HTTP mode."
158+
)
159+
logger.warning(
160+
"Redis host %s is remote but redis_ssl=False — traffic is unencrypted.",
161+
self.redis_host,
162+
)
163+
return self
125164

126165

127166
@lru_cache

everyrow-mcp/src/everyrow_mcp/http_config.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from __future__ import annotations
44

55
import logging
6+
import time as _time
67
from urllib.parse import urlparse
78

9+
from mcp.server.auth.middleware.auth_context import get_access_token
810
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
911
from mcp.server.fastmcp import FastMCP
1012
from mcp.server.fastmcp.server import lifespan_wrapper
@@ -70,16 +72,20 @@ def configure_http_mode(
7072
mcp.settings.host = host
7173
mcp.settings.port = port
7274

73-
if not no_auth and not settings.upload_secret:
75+
if not settings.upload_secret:
7476
raise RuntimeError(
7577
"UPLOAD_SECRET must be set in HTTP mode for HMAC signing. "
7678
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
7779
)
78-
if settings.is_http and not no_auth and not settings.redis_password:
79-
logger.warning(
80+
if not no_auth and not settings.redis_password:
81+
raise RuntimeError(
8082
"REDIS_PASSWORD is not set — Redis is unauthenticated. "
8183
"Set REDIS_PASSWORD for production deployments."
8284
)
85+
if no_auth and not settings.redis_password:
86+
logger.warning(
87+
"REDIS_PASSWORD is not set — acceptable for local development only."
88+
)
8389

8490
_register_widgets(mcp, mcp_server_url)
8591
_register_routes(mcp, redis_client, auth_provider if not no_auth else None)
@@ -164,23 +170,31 @@ def _ui_csp(connect_domains: list[str]) -> dict[str, str | list[str]]:
164170

165171

166172
class _RequestLoggingMiddleware(BaseHTTPMiddleware):
167-
"""Log every inbound request and its response status at DEBUG level."""
173+
"""Log inbound requests at INFO level with method, path, status, and timing."""
168174

169175
async def dispatch(self, request, call_next):
170-
has_auth = "authorization" in request.headers
171-
logger.debug(
172-
"INCOMING %s %s | Host: %s | Auth: %s",
173-
request.method,
174-
request.url.path,
175-
request.headers.get("host", "?"),
176-
"present" if has_auth else "none",
177-
)
176+
# Skip health check requests — k8s probes hit these every ~10s.
177+
if request.url.path == "/health":
178+
return await call_next(request)
179+
180+
start = _time.monotonic()
178181
response = await call_next(request)
179-
logger.debug(
180-
"RESPONSE %s %s -> %s",
182+
elapsed_ms = (_time.monotonic() - start) * 1000
183+
184+
# Extract user_id from the access token if available.
185+
try:
186+
access_token = get_access_token()
187+
user_id = access_token.client_id if access_token else None
188+
except Exception:
189+
user_id = None
190+
191+
logger.info(
192+
"HTTP %s %s -> %d (%.0fms) user=%s",
181193
request.method,
182194
request.url.path,
183195
response.status_code,
196+
elapsed_ms,
197+
user_id or "anon",
184198
)
185199
return response
186200

everyrow-mcp/src/everyrow_mcp/middleware.py

Lines changed: 14 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
from __future__ import annotations
44

55
import logging
6-
import threading
76
import time
87

98
from redis.asyncio import Redis
10-
from redis.exceptions import RedisError
119
from starlette.middleware.base import BaseHTTPMiddleware
1210
from starlette.requests import Request
1311
from starlette.responses import JSONResponse, Response
@@ -46,11 +44,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
4644
"""Redis-based fixed-window rate limiter per client IP.
4745
4846
Returns 429 with ``Retry-After`` header when the limit is exceeded.
49-
Falls back to an in-memory counter when Redis is unavailable.
47+
Redis is a hard dependency in HTTP mode — failures propagate.
5048
"""
5149

52-
_MEM_CLEANUP_INTERVAL = 100 # clean up stale entries every N requests
53-
5450
def __init__(
5551
self,
5652
app,
@@ -63,42 +59,6 @@ def __init__(
6359
self._redis = redis
6460
self._max_requests = max_requests
6561
self._window_seconds = window_seconds
66-
# In-memory fallback: {key: (count, window_start)}
67-
self._mem_counters: dict[str, tuple[int, float]] = {}
68-
self._mem_lock = threading.Lock()
69-
self._mem_request_count = 0
70-
71-
def _check_in_memory(self, ip: str) -> bool:
72-
"""In-memory fixed-window rate check. Returns True if the request should be blocked."""
73-
now = time.time()
74-
window_start = (int(now) // self._window_seconds) * self._window_seconds
75-
key = f"{ip}:{window_start}"
76-
77-
with self._mem_lock:
78-
self._mem_request_count += 1
79-
if self._mem_request_count % self._MEM_CLEANUP_INTERVAL == 0:
80-
self._cleanup_mem_counters(now)
81-
82-
count, ws = self._mem_counters.get(key, (0, window_start))
83-
count += 1
84-
self._mem_counters[key] = (count, ws)
85-
return count > self._max_requests
86-
87-
_MAX_MEM_ENTRIES = 50_000 # hard cap to prevent unbounded memory growth
88-
89-
def _cleanup_mem_counters(self, now: float) -> None:
90-
"""Evict stale entries. Must be called under _mem_lock."""
91-
cutoff = now - self._window_seconds * 2
92-
stale = [k for k, (_, ws) in self._mem_counters.items() if ws < cutoff]
93-
for k in stale:
94-
del self._mem_counters[k]
95-
# Hard cap: if still too many entries, evict oldest
96-
if len(self._mem_counters) > self._MAX_MEM_ENTRIES:
97-
sorted_keys = sorted(
98-
self._mem_counters, key=lambda k: self._mem_counters[k][1]
99-
)
100-
for k in sorted_keys[: len(self._mem_counters) - self._MAX_MEM_ENTRIES]:
101-
del self._mem_counters[k]
10262

10363
async def dispatch(self, request: Request, call_next) -> Response:
10464
if request.url.path == "/health":
@@ -113,30 +73,20 @@ async def dispatch(self, request: Request, call_next) -> Response:
11373
window_id = str(int(time.time()) // self._window_seconds)
11474
key = build_key("rate", client_ip, window_id)
11575

116-
try:
117-
async with self._redis.pipeline() as pipe:
118-
pipe.incr(key)
119-
pipe.expire(key, self._window_seconds, nx=True)
120-
count, _ = await pipe.execute()
121-
122-
if count > self._max_requests:
123-
ttl = await self._redis.ttl(key)
124-
retry_after = max(ttl, 1)
125-
return JSONResponse(
126-
{"detail": "Rate limit exceeded"},
127-
status_code=429,
128-
headers={"Retry-After": str(retry_after)},
129-
)
130-
except (RedisError, OSError):
131-
logger.warning(
132-
"Rate-limit check failed (Redis unavailable), using in-memory fallback"
76+
async with self._redis.pipeline() as pipe:
77+
pipe.incr(key)
78+
pipe.expire(key, self._window_seconds)
79+
count, _ = await pipe.execute()
80+
81+
if count > self._max_requests:
82+
logger.warning("Rate limit exceeded for IP %s", client_ip)
83+
ttl = await self._redis.ttl(key)
84+
retry_after = max(ttl, 1)
85+
return JSONResponse(
86+
{"detail": "Rate limit exceeded"},
87+
status_code=429,
88+
headers={"Retry-After": str(retry_after)},
13389
)
134-
if self._check_in_memory(client_ip):
135-
return JSONResponse(
136-
{"detail": "Rate limit exceeded"},
137-
status_code=429,
138-
headers={"Retry-After": str(self._window_seconds)},
139-
)
14090

14191
return await call_next(request)
14292

everyrow-mcp/src/everyrow_mcp/redis_store.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ def encrypt_value(value: str) -> str:
7171
"""Encrypt a string value for Redis storage. No-op without UPLOAD_SECRET."""
7272
f = _get_fernet()
7373
if f is None:
74+
if settings.is_http:
75+
raise RuntimeError(
76+
"UPLOAD_SECRET must be set in HTTP mode — cannot store sensitive values in plaintext."
77+
)
7478
return value
7579
return f.encrypt(value.encode()).decode()
7680

@@ -79,6 +83,10 @@ def decrypt_value(value: str) -> str:
7983
"""Decrypt a string value from Redis. No-op without UPLOAD_SECRET."""
8084
f = _get_fernet()
8185
if f is None:
86+
if settings.is_http:
87+
raise RuntimeError(
88+
"UPLOAD_SECRET must be set in HTTP mode — cannot read encrypted values without the key."
89+
)
8290
return value
8391
return f.decrypt(value.encode()).decode()
8492

@@ -278,6 +286,11 @@ async def store_upload_meta(upload_id: str, meta_json: str, ttl: int) -> None:
278286
await get_redis_client().setex(build_key("upload", upload_id), ttl, meta_json)
279287

280288

289+
async def get_upload_meta(upload_id: str) -> str | None:
290+
"""Read upload metadata without consuming it."""
291+
return await get_redis_client().get(build_key("upload", upload_id))
292+
293+
281294
async def pop_upload_meta(upload_id: str) -> str | None:
282295
"""Atomically get and delete upload metadata (prevents replay)."""
283296
key = build_key("upload", upload_id)

0 commit comments

Comments
 (0)