-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathapp.py
More file actions
2778 lines (2309 loc) · 99.8 KB
/
Copy pathapp.py
File metadata and controls
2778 lines (2309 loc) · 99.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""FastAPI application for CrawlLama - Production-ready API."""
import asyncio
import copy
import hmac
import ipaddress
import json
import logging
import os
import re
import secrets
import sys
import threading
import time
import unicodedata
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any
import dotenv
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator
from core.agent import SearchAgent
from core.agent.constants import QUICK_RESULT_REFERENCE_PATTERN
from core.api_key_manager import get_api_key_manager
from core.audit_logger import get_audit_logger
from core.csrf_manager import get_csrf_manager, validate_origin_header, validate_referer_header
from core.health import get_performance_tracker, get_system_monitor, print_health_summary, shutdown_monitoring
from core.langgraph_agent import (
DEFAULT_CONFIDENCE_THRESHOLD,
DEFAULT_MAX_HOPS,
MultiHopReasoningAgent,
create_multihop_agent,
)
from core.memory_store import MemoryStore
from core.rbac_manager import Role, get_rbac_manager, get_role_hierarchy
from core.unified_loader import get_unified_loader
from utils.redis_rate_limiter import RedisRateLimiter, get_rate_limit_for_endpoint
from utils.secure_hash import hmac_sha256_hex
from utils.tor_mode import TorError, initialize_tor_mode
from utils.validators import sanitize_exception_message, sanitize_query
# Load environment variables
dotenv.load_dotenv()
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("crawllama")
# Version constant (single source of truth in _version.py)
from _version import __version__ as VERSION # noqa: E402 - needs logger configured first
def is_dev_mode() -> bool:
"""Return True when CRAWLLAMA_DEV_MODE is enabled (relaxed security for local development)."""
return os.getenv("CRAWLLAMA_DEV_MODE", "false").lower() == "true"
# Security: Load API key from environment or generate temporary one
API_KEY = os.getenv("CRAWLLAMA_API_KEY", None)
if not API_KEY:
API_KEY = secrets.token_urlsafe(32)
# SECURITY: Never log the actual API key - only indicate one was generated
logger.warning("No API_KEY set in environment. Generated temporary key for this session.")
logger.warning("IMPORTANT: Set CRAWLLAMA_API_KEY in .env for production!")
logger.warning("Retrieve the temporary key via /dev/api-key endpoint (only available in DEV_MODE)")
# Security: HMAC secret for rate limiting (cryptographically secure hashing)
RATE_LIMIT_SECRET = os.getenv("RATE_LIMIT_SECRET", None)
# Track whether the secret is ephemeral (regenerated each start). An ephemeral
# secret silently invalidates all previously issued managed API-key hashes after
# a restart, so it must never be used in production (enforced at startup below).
RATE_LIMIT_SECRET_EPHEMERAL = not RATE_LIMIT_SECRET
if not RATE_LIMIT_SECRET:
RATE_LIMIT_SECRET = secrets.token_bytes(32) # 256-bit secret
logger.warning("No RATE_LIMIT_SECRET set. Generated temporary secret for this session.")
logger.warning("Set RATE_LIMIT_SECRET in .env for consistent rate limiting across restarts.")
elif isinstance(RATE_LIMIT_SECRET, str):
# Convert string to bytes if loaded from env
RATE_LIMIT_SECRET = RATE_LIMIT_SECRET.encode('utf-8')
# Initialize CSRF Manager
csrf_manager = get_csrf_manager()
logger.info("CSRF protection initialized")
# Initialize RBAC Manager
rbac_manager = get_rbac_manager()
logger.info("RBAC (Role-Based Access Control) initialized")
# Initialize Audit Logger
audit_logger = get_audit_logger()
logger.info("Audit logging initialized")
# Initialize API Key Manager (for rotation support)
api_key_manager = get_api_key_manager()
logger.info("API key rotation manager initialized")
# Initialize FastAPI app
@asynccontextmanager
async def lifespan(_: FastAPI):
"""Run startup/shutdown logic (modern replacement for on_event hooks)."""
await startup_event()
yield
await shutdown_event()
app = FastAPI(
title="CrawlLama API",
description="AI-powered web research agent with multi-hop reasoning",
version=VERSION,
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan
)
# Mount static files for web interface
try:
app.mount("/static", StaticFiles(directory="static"), name="static")
logger.info("Static files mounted successfully")
except Exception as e:
logger.warning(f"Could not mount static files: {e}")
# Security: Trusted Host Middleware (prevent Host header attacks)
# Get allowed hosts from env or use secure defaults (no wildcard in production!).
# 0.0.0.0 is a bind address, never a valid Host header value, so it is not an
# allowed host here.
allowed_hosts = [h.strip() for h in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h.strip()]
# Add testserver only for the in-process test client (Starlette TestClient uses
# Host: testserver). Never enabled outside an explicit test environment.
if os.getenv("CRAWLLAMA_TESTING", "false").lower() == "true" or "PYTEST_CURRENT_TEST" in os.environ or "pytest" in sys.modules:
allowed_hosts.append("testserver")
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=allowed_hosts
)
# CORS configuration
# SECURITY: No wildcard default - must be explicitly configured in production
cors_origins_env = os.getenv("ALLOWED_ORIGINS", "")
if cors_origins_env:
# Strip whitespace so " https://app.example" entries still match, and drop empties.
cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()]
# SECURITY: a credentialed wildcard ("*" with allow_credentials=True) lets any
# site read authenticated responses. Refuse it and fall back to safe defaults.
if "*" in cors_origins:
logger.error(
"ALLOWED_ORIGINS contains '*', which is unsafe with credentialed CORS. "
"Ignoring the wildcard and using restrictive localhost defaults instead."
)
cors_origins = [o for o in cors_origins if o != "*"]
if not cors_origins:
cors_origins = ["http://localhost:3000", "http://localhost:8000", "http://127.0.0.1:3000", "http://127.0.0.1:8000"]
else:
# Development default - restrictive
cors_origins = ["http://localhost:3000", "http://localhost:8000", "http://127.0.0.1:3000", "http://127.0.0.1:8000"]
logger.warning("No ALLOWED_ORIGINS set. Using development defaults. Set ALLOWED_ORIGINS for production!")
# Store globally for CSRF validation
ALLOWED_ORIGINS = cors_origins
ALLOWED_HOSTS_LIST = allowed_hosts
# Paths exempt from rate limiting and audit logging (public docs/health endpoints)
PUBLIC_PATHS = frozenset({"/health", "/", "/docs", "/redoc", "/openapi.json"})
# Paths exempt from CSRF Origin/Referer validation
CSRF_EXEMPT_PATHS = PUBLIC_PATHS | {"/csrf-token"}
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Content-Type", "Authorization", "X-API-Key", "X-CSRF-Token", "X-Requested-With"], # Whitelist instead of wildcard
)
# Security Headers Middleware
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
"""Add security headers to all responses."""
response = await call_next(request)
# Content Security Policy - Prevent XSS attacks
# Strengthened CSP with stricter policies
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; " # Removed 'unsafe-inline' for better XSS protection
"style-src 'self' 'unsafe-inline'; " # Still needed for inline styles
"img-src 'self' data: https:; "
"connect-src 'self'; "
"font-src 'self' data:; "
"object-src 'none'; " # Block plugins
"base-uri 'self'; " # Prevent base tag injection
"form-action 'self'; " # Restrict form submissions
"frame-ancestors 'none'; " # Prevent clickjacking
"upgrade-insecure-requests;" # Upgrade HTTP to HTTPS
)
# Prevent MIME type sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Prevent clickjacking
response.headers["X-Frame-Options"] = "DENY"
# XSS Protection (legacy but still useful)
response.headers["X-XSS-Protection"] = "1; mode=block"
# Referrer Policy - Control information leakage
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Permissions Policy - Disable unnecessary browser features
response.headers["Permissions-Policy"] = (
"geolocation=(), "
"microphone=(), "
"camera=(), "
"payment=(), "
"usb=(), "
"magnetometer=()"
)
# HSTS - Force HTTPS (only in production with HTTPS)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
return response
# CSRF Origin/Referer Validation Middleware
@app.middleware("http")
async def csrf_origin_referer_middleware(request: Request, call_next):
"""
Validate Origin and Referer headers for state-changing requests.
Provides CSRF protection by ensuring requests originate from trusted sources.
Only applies to POST, PUT, PATCH, DELETE methods.
"""
# Skip for safe methods (GET, HEAD, OPTIONS)
if request.method in ["GET", "HEAD", "OPTIONS"]:
return await call_next(request)
# Skip for public endpoints and dev endpoints
if request.url.path in CSRF_EXEMPT_PATHS:
return await call_next(request)
# Skip in DEV_MODE — but only for loopback clients (see _dev_bypass_allowed)
if _dev_bypass_allowed(request):
return await call_next(request)
# Validate Origin header (preferred)
origin = request.headers.get("Origin")
if origin:
if not validate_origin_header(origin, ALLOWED_ORIGINS):
logger.warning(f"CSRF: Invalid Origin header: {origin}")
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Invalid Origin header. CSRF protection triggered."}
)
else:
# Fallback to Referer header validation
referer = request.headers.get("Referer")
if referer:
if not validate_referer_header(referer, ALLOWED_HOSTS_LIST):
logger.warning(f"CSRF: Invalid Referer header: {referer}")
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Invalid Referer header. CSRF protection triggered."}
)
else:
# No Origin or Referer header - reject for security
logger.warning("CSRF: Missing Origin and Referer headers for state-changing request")
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Missing Origin/Referer header. CSRF protection requires these headers for state-changing requests."}
)
return await call_next(request)
# Audit Logging Middleware
@app.middleware("http")
async def audit_logging_middleware(request: Request, call_next):
"""
Audit logging middleware for security and compliance.
Logs all API requests with user, endpoint, status, and timing information.
"""
# Skip audit logging for health check and static files
if request.url.path in PUBLIC_PATHS:
return await call_next(request)
# Record start time
start_time = time.time()
# Get user identifier
api_key = request.headers.get("X-API-Key", "")
if api_key and api_key != "dev":
user_id = _short_id(hash_api_key_for_logging(api_key))
else:
user_id = request.client.host if request.client else "unknown"
# Get client IP
client_ip = request.client.host if request.client else "unknown"
# Process request
response = None
error = None
try:
response = await call_next(request)
return response
except Exception as e:
error = str(e)
raise
finally:
# Calculate response time
response_time = time.time() - start_time
# Get status code
status_code = response.status_code if response else 500
# Log to audit system
try:
audit_logger.log_api_request(
user_id=user_id,
endpoint=request.url.path,
method=request.method,
status_code=status_code,
response_time=response_time,
ip_address=client_ip,
error=error
)
except Exception as audit_error:
logger.error(f"Audit logging failed: {audit_error}")
def _rate_limit_user_id(request: Request) -> str:
"""Derive a stable, non-reversible rate-limiting identifier (API key hash or client IP)."""
api_key = request.headers.get("X-API-Key", "")
if not api_key or api_key == "dev" or is_dev_mode():
return request.client.host if request.client else "unknown"
# SECURITY: Use a keyed HMAC (default SHA3-256) to derive a stable
# identifier for rate limiting while preventing reversal of API keys.
# The API key is immediately hashed with a secret key and never stored/logged in plaintext
# This is the CORRECT way to handle API keys for rate limiting (not password storage)
return hmac_sha256_hex(api_key, key=RATE_LIMIT_SECRET) # deterministic, keyed ID
# Redis Rate Limiting Middleware
@app.middleware("http")
async def redis_rate_limit_middleware(request: Request, call_next):
"""
Redis-based distributed rate limiting middleware.
Implements Token Bucket algorithm with per-user, per-endpoint limits.
Falls back to in-memory rate limiting if Redis unavailable.
"""
# Skip rate limiting for health check and root endpoints
if request.url.path in PUBLIC_PATHS:
return await call_next(request)
# Redis not available - use in-memory rate limiting (legacy behavior)
if not redis_rate_limiter:
return await call_next(request)
user_id = _rate_limit_user_id(request)
endpoint = request.url.path
limit, window = get_rate_limit_for_endpoint(endpoint)
allowed, info = redis_rate_limiter.check_rate_limit(
user_id=user_id,
endpoint=endpoint,
limit=limit,
window=window
)
if not allowed:
# Rate limit exceeded - return 429
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={
"detail": f"Rate limit exceeded. Maximum {limit} requests per {window} seconds.",
"retry_after": info["retry_after"],
"reset_at": info["reset_at"]
},
headers={
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(info["reset_at"]),
"Retry-After": str(info["retry_after"])
}
)
# Rate limit OK - add headers and continue
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(limit)
response.headers["X-RateLimit-Remaining"] = str(info["remaining"])
response.headers["X-RateLimit-Reset"] = str(info["reset_at"])
return response
# Load configuration
try:
with open("config.json") as f:
config = json.load(f)
except Exception as e:
logger.error(f"Failed to load config: {e}")
config = {
"llm": {"model": "qwen2.5:3b"},
"cache": {"enabled": True}
}
# Initialize components
agent = None
multihop_agent = None
memory_store = None
system_monitor = None
performance_tracker = None
redis_rate_limiter = None # Redis-based rate limiter
adaptive_manager = None # Adaptive hopping manager
adaptive_query_processor = None # Adaptive query processor
def validate_security_configuration():
"""Validate security configuration on startup.
Checks for insecure configurations and logs warnings/errors.
Only validates, does not block startup (except in strict mode).
"""
issues = []
warnings = []
# Check if in DEV_MODE
dev_mode = is_dev_mode()
if dev_mode:
warnings.append("⚠️ DEV_MODE is enabled - Security checks are relaxed")
# Check API key configuration
if not os.getenv("CRAWLLAMA_API_KEY"):
if not dev_mode:
warnings.append("⚠️ No CRAWLLAMA_API_KEY set - Using temporary key (insecure for production)")
else:
api_key = os.getenv("CRAWLLAMA_API_KEY")
if len(api_key) < 32:
issues.append("❌ CRAWLLAMA_API_KEY is too short (min 32 characters recommended)")
# Check ALLOWED_HOSTS configuration
if not os.getenv("ALLOWED_HOSTS"):
warnings.append("⚠️ No ALLOWED_HOSTS set - Using defaults (configure for production)")
# Check ALLOWED_ORIGINS configuration
if not os.getenv("ALLOWED_ORIGINS"):
warnings.append("⚠️ No ALLOWED_ORIGINS set - Using defaults (configure for production)")
# Check RATE_LIMIT_SECRET
if not os.getenv("RATE_LIMIT_SECRET"):
warnings.append("⚠️ No RATE_LIMIT_SECRET set - Using temporary secret")
# Check Redis configuration
if not os.getenv("REDIS_URL"):
warnings.append("ℹ️ No REDIS_URL set - Using in-memory fallbacks (not distributed)")
# Log results
if issues:
logger.error("=" * 60)
logger.error("🚨 SECURITY CONFIGURATION ISSUES 🚨")
for issue in issues:
logger.error(issue)
logger.error("=" * 60)
if warnings:
logger.warning("=" * 60)
logger.warning("🔒 SECURITY CONFIGURATION WARNINGS")
for warning in warnings:
logger.warning(warning)
logger.warning("=" * 60)
if not issues and not warnings:
logger.info("✅ Security configuration validation passed")
# In production strict mode, block startup on issues
strict_mode = os.getenv("SECURITY_STRICT_MODE", "false").lower() == "true"
if strict_mode and issues:
logger.critical("SECURITY_STRICT_MODE enabled - Blocking startup due to security issues")
raise RuntimeError("Security configuration validation failed in strict mode")
return len(issues) == 0
def _is_loopback_host(host: str) -> bool:
"""Return True only for loopback bind addresses (127.0.0.0/8, ::1, localhost)."""
h = (host or "").strip().lower()
if h == "localhost":
return True
try:
return ipaddress.ip_address(h).is_loopback
except ValueError:
# Any other hostname (or 0.0.0.0/:: which are non-loopback) is treated
# as network-reachable.
return False
def _dev_bypass_allowed(request: Request | None) -> bool:
"""Whether DEV_MODE's auth/CSRF/RBAC bypass may apply to THIS request.
SECURITY (defence-in-depth): the startup guard (`_enforce_dev_mode_loopback_only`)
only sees the *env-configured* bind host, so it cannot catch a launch like
`uvicorn app:app --host 0.0.0.0`. To close that gap, the DEV_MODE relaxations
are additionally gated per-request on the client being loopback. A remote
client always gets the full auth/CSRF/RBAC stack even if DEV_MODE is on.
"""
if not is_dev_mode():
return False
if request is None or request.client is None:
return False
return _is_loopback_host(request.client.host)
def _enforce_dev_mode_loopback_only(dev_mode: bool) -> None:
"""Refuse to start in DEV_MODE when bound to a non-loopback address.
SECURITY (fail closed): DEV_MODE disables API-key auth, CSRF and RBAC on
EVERY endpoint (including /admin/* and /config). That is only acceptable on
loopback. If the configured bind host is network-reachable, a single
env-var misconfiguration would expose the entire unauthenticated admin
surface — so refuse to start instead.
"""
if not dev_mode:
return
host = (
os.getenv("CRAWLLAMA_HOST")
or os.getenv("HOST")
or os.getenv("UVICORN_HOST")
or "127.0.0.1"
)
if _is_loopback_host(host):
return
logger.critical(
"CRAWLLAMA_DEV_MODE is enabled but the server is configured to bind a "
f"non-loopback host ({host}). DEV_MODE disables authentication, CSRF and "
"RBAC and must never be network-reachable. Refusing to start — unset "
"CRAWLLAMA_DEV_MODE for production or bind to 127.0.0.1."
)
raise RuntimeError("DEV_MODE must not be bound to a non-loopback host")
def _enforce_persistent_rate_limit_secret(dev_mode: bool) -> None:
"""Refuse to start outside DEV_MODE without a persistent RATE_LIMIT_SECRET.
SECURITY (fail closed): an ephemeral RATE_LIMIT_SECRET invalidates every
issued managed-key hash on restart and yields non-portable rate-limit
identities. Refuse to start in production without a persistent secret.
"""
if RATE_LIMIT_SECRET_EPHEMERAL and not dev_mode:
logger.critical(
"RATE_LIMIT_SECRET is not set. Refusing to start outside DEV_MODE. "
"Set RATE_LIMIT_SECRET in the environment for a persistent secret."
)
raise RuntimeError("RATE_LIMIT_SECRET is required outside DEV_MODE")
def _grant_bootstrap_admin_role() -> None:
"""Grant the bootstrap key administrative privileges.
With the secure default role now READ_ONLY, the operator's bootstrap key
must be explicitly elevated so that admin endpoints remain reachable out
of the box.
"""
try:
bootstrap_user_id = hash_api_key_for_logging(API_KEY)
rbac_manager.assign_role(bootstrap_user_id, Role.ADMIN, user_info="bootstrap")
logger.info("Bootstrap API key granted ADMIN role")
except Exception as e:
logger.error(f"Failed to assign bootstrap admin role: {e}", exc_info=True)
def _create_redis_rate_limiter() -> RedisRateLimiter | None:
"""Initialize the Redis rate limiter; return None to fall back to in-memory limiting."""
try:
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
limiter = RedisRateLimiter(redis_url=redis_url)
logger.info(f"Redis rate limiter initialized: {redis_url}")
return limiter
except Exception as e:
logger.warning(f"Failed to initialize Redis rate limiter: {e}")
logger.warning("Falling back to in-memory rate limiting")
return None
def _create_health_monitoring() -> tuple:
"""Initialize health monitoring; return (system_monitor, performance_tracker) or (None, None)."""
try:
monitor = get_system_monitor()
tracker = get_performance_tracker()
logger.info("Health monitoring initialized")
return monitor, tracker
except Exception as e:
logger.error(f"Failed to initialize health monitoring: {e}", exc_info=True)
# Continue without health monitoring
return None, None
def _create_memory_store() -> MemoryStore | None:
"""Initialize the memory store; return None on failure."""
try:
store = MemoryStore()
logger.info("Memory store initialized")
return store
except Exception as e:
logger.error(f"Failed to initialize memory store: {e}", exc_info=True)
# Continue without memory store
return None
def _create_standard_agent() -> SearchAgent | None:
"""Initialize the standard search agent; return None on failure."""
try:
search_agent = SearchAgent(config=config, enable_web=True, debug=False)
logger.info("Standard agent initialized")
return search_agent
except Exception as e:
logger.error(f"Failed to initialize standard agent: {e}", exc_info=True)
# Continue without standard agent
return None
def _create_multihop_agent() -> MultiHopReasoningAgent | None:
"""Initialize the multi-hop reasoning agent; return None on failure."""
try:
reasoning_agent = create_multihop_agent(config)
logger.info("Multi-hop agent initialized")
return reasoning_agent
except Exception as e:
logger.error(f"Failed to initialize multi-hop agent: {e}", exc_info=True)
# Continue without multi-hop agent
return None
def _create_adaptive_system(search_agent, reasoning_agent, monitor, tracker) -> tuple:
"""Initialize the Adaptive Hopping System; return (manager, processor) or (None, None)."""
try:
from core.adaptive_integration import initialize_adaptive_system
from core.cloud_llm_client import create_llm_client_from_config
# LLM client for complexity detection - provider-aware, so cloud
# configs get working adaptive features too (previously Ollama-only).
llm = create_llm_client_from_config(config.get("llm", {}))
manager, processor = initialize_adaptive_system(
llm=llm,
agent=search_agent,
multihop_agent=reasoning_agent,
system_monitor=monitor,
performance_tracker=tracker
)
logger.info("Adaptive Hopping System initialized")
return manager, processor
except Exception as e:
logger.error(f"Failed to initialize Adaptive Hopping System: {e}", exc_info=True)
logger.warning("API will continue without adaptive features")
return None, None
async def startup_event():
"""Initialize components on startup."""
global agent, multihop_agent, memory_store, system_monitor, performance_tracker, redis_rate_limiter, adaptive_manager, adaptive_query_processor
logger.info("Starting CrawlLama API...")
# Tor mode must be active (and verified) before any component can make a
# web request; an unreachable Tor network aborts startup (fail fast).
try:
tor_config = initialize_tor_mode(config)
except TorError as e:
logger.critical(f"Startup aborted: {e}")
raise
if tor_config.enabled:
logger.info(f"Tor mode active: all web traffic routed via {tor_config.proxy_url}")
dev_mode = is_dev_mode()
_enforce_dev_mode_loopback_only(dev_mode)
_enforce_persistent_rate_limit_secret(dev_mode)
# Validate security configuration FIRST
try:
validate_security_configuration()
except RuntimeError as e:
logger.critical(f"Startup aborted: {e}")
raise
if not dev_mode:
_grant_bootstrap_admin_role()
# Store startup time for uptime calculation
app.state.start_time = time.time()
# Initialize components (each falls back to None/degraded mode on failure)
redis_rate_limiter = _create_redis_rate_limiter()
system_monitor, performance_tracker = _create_health_monitoring()
memory_store = _create_memory_store()
agent = _create_standard_agent()
multihop_agent = _create_multihop_agent()
# Check if critical components are initialized
if not agent and not multihop_agent:
logger.warning("WARNING: No agents initialized! API will have limited functionality.")
adaptive_manager, adaptive_query_processor = _create_adaptive_system(
agent, multihop_agent, system_monitor, performance_tracker
)
logger.info("CrawlLama API started successfully")
async def shutdown_event():
"""Cleanup on shutdown."""
logger.info("Shutting down CrawlLama API...")
# Close Redis connection
if redis_rate_limiter:
try:
redis_rate_limiter.close()
logger.info("Redis rate limiter closed")
except Exception as e:
logger.error(f"Error closing Redis rate limiter: {e}")
# Print final health summary
if system_monitor and performance_tracker:
logger.info("Final health summary:")
print_health_summary()
# Shutdown monitoring
shutdown_monitoring()
logger.info("CrawlLama API shutdown complete")
# Request logging middleware
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log all requests with timing."""
start_time = time.time()
# Log request
logger.info(f"Request: {request.method} {request.url.path}")
# Process request
try:
response = await call_next(request)
# Log response
duration = time.time() - start_time
logger.info(
f"Response: {request.method} {request.url.path} "
f"Status: {response.status_code} Duration: {duration:.3f}s"
)
# Add timing header
response.headers["X-Process-Time"] = str(duration)
return response
except Exception as e:
logger.error(f"Request failed: {request.method} {request.url.path} Error: {e}")
raise
# Rate limiting (simple in-memory, use Redis for production)
request_counts: dict[str, list[float]] = {}
# Async lock prevents anyio/threadpool deadlocks in ASGI test environments.
rate_limit_lock = asyncio.Lock()
config_lock = threading.Lock() # Thread-safe config file writes
RATE_LIMIT = int(os.getenv("RATE_LIMIT", "60")) # requests per minute
# Per-IP cap on FAILED authentication attempts per minute. Unlike the per-principal
# limiter above (which only counts authenticated callers), this throttles unauthenticated
# invalid-key requests to bound credential brute-forcing and the storage lookups they cause.
AUTH_FAILURE_LIMIT = int(os.getenv("AUTH_FAILURE_LIMIT", "20"))
auth_failure_counts: dict[str, list[float]] = {}
MAX_QUERY_LENGTH = 5000 # Maximum query length
MAX_MEMORY_ENTRIES = 10000 # Maximum memory entries per category
def hash_api_key_for_logging(key: str) -> str:
"""
Hash API key for secure logging.
Uses HMAC-SHA256 truncated to 16 characters to prevent key exposure in logs
while maintaining uniqueness for debugging purposes.
HMAC provides cryptographic security against length extension attacks.
Args:
key: The API key to hash
Returns:
Hashed key (16 hex chars) or original if it's a special value
"""
# Don't hash special values. Return the matched literal rather than the
# input so no code path can ever propagate the raw key to a caller.
if key == "unknown":
return "unknown"
if key == "dev":
return "dev"
# Don't hash IP addresses (both IPv4 and IPv6). Return the canonical form
# rebuilt from the parsed address, again never the raw input string.
try:
return str(ipaddress.ip_address(key))
except ValueError:
pass # Not an IP address, proceed with hashing
# SECURITY: Use a keyed HMAC (default SHA3-256) to derive a stable
# identifier for logging while preventing reversal of sensitive keys.
# The key is immediately hashed with a secret and never stored/logged in plaintext
return hmac_sha256_hex(key, key=RATE_LIMIT_SECRET) # deterministic, keyed ID
def _short_id(value: str) -> str:
"""Truncate an identifier/hash to a short, log-safe display form."""
return f"{value[:16]}..."
async def _auth_failure_budget_ok(client_ip: str) -> bool:
"""Return False when this IP has exhausted its failed-auth budget for the window.
Sliding 1-minute window. 429 responses are NOT counted as new failures, so a
throttled IP recovers automatically once its recent failures age out.
"""
now = time.time()
async with rate_limit_lock:
# Bound memory: drop IPs with no recent failures.
if len(auth_failure_counts) > 1024:
for k in [k for k, ts in auth_failure_counts.items()
if not ts or now - ts[-1] >= 60]:
del auth_failure_counts[k]
recent = [t for t in auth_failure_counts.get(client_ip, []) if now - t < 60]
auth_failure_counts[client_ip] = recent
return len(recent) < AUTH_FAILURE_LIMIT
async def _record_auth_failure(client_ip: str) -> None:
"""Record one failed authentication attempt for this IP (sliding window)."""
now = time.time()
async with rate_limit_lock:
auth_failure_counts.setdefault(client_ip, []).append(now)
async def verify_api_key(request: Request, x_api_key: str | None = Header(None)):
"""Verify API key for authentication.
Accepts two kinds of credentials:
1. The bootstrap key (``CRAWLLAMA_API_KEY``), compared in constant time.
2. Any active, non-expired key issued through the rotation-capable
:class:`APIKeyManager` (``/admin/api-keys/*``).
Returns the authenticated plaintext key so downstream dependencies can
derive a stable per-principal identifier and (for managed keys) drive
rotation/revocation.
"""
# Skip API key check in DEV_MODE — but only for loopback clients
if _dev_bypass_allowed(request):
return "dev"
client_ip = request.client.host if request.client else "unknown"
# SECURITY: throttle brute-force. If this IP has already burned its failed-auth
# budget this minute, reject early — before the storage-backed validate_key()
# lookup — so invalid keys cannot be sprayed unboundedly.
if not await _auth_failure_budget_ok(client_ip):
logger.warning("Authentication failure rate limit exceeded")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many failed authentication attempts. Try again later.",
)
if not x_api_key:
await _record_auth_failure(client_ip)
logger.warning("Missing API key attempt")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
headers={"WWW-Authenticate": "ApiKey"},
)
# 1. Constant-time comparison against the bootstrap key (prevents timing
# side-channel recovery of the shared secret).
if hmac.compare_digest(x_api_key, API_KEY):
return x_api_key
# 2. Validate against the rotation manager (multi-key, expiry, revocation).
try:
is_valid, _user_id = api_key_manager.validate_key(x_api_key)
except Exception:
# Storage-backend error: fail closed rather than granting access.
logger.error("API key validation backend error", exc_info=True)
is_valid = False
if is_valid:
return x_api_key
# SECURITY: Never log API keys - only log that authentication failed
await _record_auth_failure(client_ip)
logger.warning("Invalid or missing API key attempt")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
headers={"WWW-Authenticate": "ApiKey"},
)
def verify_csrf_token(
request: Request,
x_csrf_token: str | None = Header(None),
api_key: str = Depends(verify_api_key)
) -> str:
"""Verify CSRF token for state-changing operations.
Args:
x_csrf_token: CSRF token from X-CSRF-Token header
api_key: Authenticated API key
Returns:
The validated CSRF token
Raises:
HTTPException: If CSRF token is invalid or missing
"""
# Skip in DEV_MODE — but only for loopback clients
if _dev_bypass_allowed(request):
return "dev"
if not x_csrf_token:
logger.warning("CSRF token missing in request")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token required for this operation. Get token from /csrf-token endpoint."
)
# Use hashed API key as user ID for CSRF validation
user_id = hash_api_key_for_logging(api_key)
# Validate token
if not csrf_manager.validate_token(user_id, x_csrf_token):
logger.warning("Invalid CSRF token")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid or expired CSRF token. Request a new token from /csrf-token endpoint."
)
return x_csrf_token
def verify_role(required_role: Role):
"""Create a dependency that verifies user has required role.
Args:
required_role: Minimum role required for access
Returns:
A FastAPI dependency function
"""
def role_checker(request: Request, api_key: str = Depends(verify_api_key)) -> str:
"""Check if user has required role.
Args:
api_key: Authenticated API key
Returns:
The API key if authorized
Raises:
HTTPException: If user lacks required permissions
"""
# Skip in DEV_MODE — but only for loopback clients
if _dev_bypass_allowed(request):
return api_key
# Get user's role
user_id = hash_api_key_for_logging(api_key)
user_role = rbac_manager.get_role(user_id)
# Check permission
if not rbac_manager.check_permission(user_id, required_role):
logger.warning(
f"Access denied: user role {user_role.value} "
f"attempted to access {required_role.value}-only endpoint"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. This endpoint requires {required_role.value} role. "
f"Your role: {user_role.value}"
)
return api_key
return role_checker