@@ -287,6 +287,72 @@ def add_security_headers(response):
287287
288288 return response
289289
290+ # --- Scanner / bot flood protection ---
291+ # Two layers:
292+ # 1. Block known scanner paths instantly (no DB, no template).
293+ # 2. Track 404-per-IP in memory; auto-block IPs that trip the threshold.
294+ # This prevents scanner floods from exhausting the DB connection pool.
295+ from collections import defaultdict
296+ import time as _time
297+
298+ _SCANNER_EXTENSIONS = ('.env' , '.php' , '.git/config' , '.git/HEAD' , '.asp' , '.aspx' , '.jsp' , '.cgi' )
299+ _SCANNER_PATHS = {
300+ '/wp-login.php' , '/wp-admin' , '/administrator' , '/xmlrpc.php' ,
301+ '/wp-content' , '/wp-includes' , '/.well-known/security.txt' ,
302+ }
303+
304+ _BLOCK_THRESHOLD = 10 # 404s from one IP before blocking
305+ _BLOCK_WINDOW = 60 # seconds to track 404 counts
306+ _BLOCK_DURATION = 300 # seconds to block an IP after threshold hit
307+ _ip_404_counts : dict [str , list ] = defaultdict (list ) # IP -> list of timestamps
308+ _ip_blocked_until : dict [str , float ] = {} # IP -> unblock time
309+
310+ @app .before_request
311+ def block_scanners ():
312+ ip = request .headers .get ('X-Forwarded-For' , request .remote_addr )
313+ if ip and ',' in ip :
314+ ip = ip .split (',' )[0 ].strip ()
315+ now = _time .monotonic ()
316+
317+ # Check if IP is currently blocked
318+ if ip in _ip_blocked_until :
319+ if now < _ip_blocked_until [ip ]:
320+ return '' , 403
321+ else :
322+ del _ip_blocked_until [ip ]
323+ _ip_404_counts .pop (ip , None )
324+
325+ # Fast reject: known scanner paths/extensions
326+ path = request .path .rstrip ('/' )
327+ if (any (path .endswith (ext ) for ext in _SCANNER_EXTENSIONS )
328+ or path in _SCANNER_PATHS ):
329+ _record_ip_404 (ip , now )
330+ return 'Not Found' , 404
331+
332+ def _record_ip_404 (ip : str , now : float ):
333+ """Track a 404 hit for an IP and block if threshold exceeded."""
334+ hits = _ip_404_counts [ip ]
335+ hits .append (now )
336+ # Trim old entries outside the window
337+ cutoff = now - _BLOCK_WINDOW
338+ _ip_404_counts [ip ] = [t for t in hits if t > cutoff ]
339+ if len (_ip_404_counts [ip ]) >= _BLOCK_THRESHOLD :
340+ _ip_blocked_until [ip ] = now + _BLOCK_DURATION
341+ current_app .logger .warning (
342+ f"Blocked IP { ip } for { _BLOCK_DURATION } s after { _BLOCK_THRESHOLD } "
343+ f"scanner hits in { _BLOCK_WINDOW } s"
344+ )
345+
346+ @app .after_request
347+ def track_404_ips (response ):
348+ """Track IPs that generate 404s from normal routes too."""
349+ if response .status_code == 404 :
350+ ip = request .headers .get ('X-Forwarded-For' , request .remote_addr )
351+ if ip and ',' in ip :
352+ ip = ip .split (',' )[0 ].strip ()
353+ _record_ip_404 (ip , _time .monotonic ())
354+ return response
355+
290356 # --- Session Management ---
291357 @app .before_request
292358 def make_session_permanent ():
@@ -804,6 +870,36 @@ def _get_approval_groups_for_template():
804870
805871 @app .context_processor
806872 def inject_active_user ():
873+ # Skip expensive DB queries if we're handling an error response
874+ # (the scanner early-return already catches most, but this handles
875+ # any other error paths that still render templates)
876+ if getattr (g , '_skip_nav_queries' , False ):
877+ return {
878+ "csp_nonce" : getattr (g , 'csp_nonce' , '' ),
879+ "active_user" : None ,
880+ "active_user_id" : None ,
881+ "active_user_roles" : [],
882+ "is_super_admin" : False ,
883+ "beta_testing_mode" : False ,
884+ "can_override_role" : False ,
885+ "role_override" : None ,
886+ "role_override_approval_group_id" : None ,
887+ "_get_approval_groups" : lambda : [],
888+ "dev_login_enabled" : app .config .get ("DEV_LOGIN_ENABLED" , False ),
889+ "google_auth_enabled" : app .config .get ("GOOGLE_AUTH_ENABLED" , False ),
890+ "keycloak_auth_enabled" : app .config .get ("KEYCLOAK_AUTH_ENABLED" , False ),
891+ "auth_provider" : app .config .get ("AUTH_PROVIDER" ),
892+ "is_impersonating" : False ,
893+ "real_user_id" : None ,
894+ "env_banner_enabled" : app .config .get ("ENV_BANNER_ENABLED" , False ),
895+ "env_banner_message" : app .config .get ("ENV_BANNER_MESSAGE" , "" ),
896+ "nav_is_budget_admin" : False ,
897+ "nav_approval_groups" : [],
898+ "nav_event_cycle" : None ,
899+ "nav_dept_memberships" : [],
900+ "nav_div_memberships" : [],
901+ }
902+
807903 u = get_active_user ()
808904 roles = active_user_roles ()
809905 has_super_admin = _has_super_admin_role ()
@@ -966,6 +1062,7 @@ def run_bootstrap_once():
9661062
9671063 @app .errorhandler (400 )
9681064 def bad_request_error (error ):
1065+ g ._skip_nav_queries = True
9691066 return render_template ('errors/404.html' , error = error ), 400
9701067
9711068 @app .errorhandler (403 )
@@ -997,25 +1094,34 @@ def unauthorized_error(error):
9971094
9981095 @app .errorhandler (404 )
9991096 def not_found_error (error ):
1097+ g ._skip_nav_queries = True
10001098 return render_template ('errors/404.html' , error = error ), 404
10011099
1100+ @app .errorhandler (405 )
1101+ def method_not_allowed_error (error ):
1102+ g ._skip_nav_queries = True
1103+ return render_template ('errors/404.html' , error = error ), 405
1104+
10021105 @app .errorhandler (500 )
10031106 def internal_error (error ):
10041107 # Rollback any pending database transaction to avoid connection issues
10051108 db .session .rollback ()
1109+ g ._skip_nav_queries = True
10061110 return render_template ('errors/500.html' , error = error ), 500
10071111
10081112 @app .errorhandler (OperationalError )
10091113 def database_connection_error (error ):
10101114 # Database connection issues (connection refused, timeout, etc.)
10111115 db .session .rollback ()
1116+ g ._skip_nav_queries = True
10121117 app .logger .error (f"Database connection error: { error } " )
10131118 return render_template ('errors/503.html' , error = error if app .debug else None ), 503
10141119
10151120 @app .errorhandler (DatabaseError )
10161121 def database_error (error ):
10171122 # Other database errors
10181123 db .session .rollback ()
1124+ g ._skip_nav_queries = True
10191125 app .logger .error (f"Database error: { error } " )
10201126 return render_template ('errors/500.html' , error = error if app .debug else None ), 500
10211127
@@ -1024,6 +1130,7 @@ def database_error(error):
10241130 @app .errorhandler (Exception )
10251131 def unhandled_exception (error ):
10261132 db .session .rollback ()
1133+ g ._skip_nav_queries = True
10271134 app .logger .error (f"Unhandled exception: { error } " , exc_info = True )
10281135 return render_template ('errors/500.html' , error = None ), 500
10291136
0 commit comments