wafaHell 0.2.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wafaHell
3
- Version: 0.2.0
3
+ Version: 1.1.0
4
4
  Summary: Middleware WAF to Flask
5
5
  Author-email: Yago Martins <yagomartins30@gmail.com>
6
6
  License: MIT License
@@ -33,6 +33,10 @@ Requires-Python: >=3.9
33
33
  Description-Content-Type: text/markdown
34
34
  License-File: LICENSE
35
35
  Requires-Dist: Flask>=2.0
36
+ Requires-Dist: sqlalchemy>=2.0.0
37
+ Requires-Dist: requests
38
+ Requires-Dist: diskcache
39
+ Requires-Dist: geoip2
36
40
  Dynamic: license-file
37
41
 
38
42
  # WafHell
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wafaHell"
7
- version = "0.2.0"
7
+ version = "1.1.0"
8
8
  description = "Middleware WAF to Flask"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -19,5 +19,14 @@ classifiers = [
19
19
  "Operating System :: OS Independent"
20
20
  ]
21
21
  dependencies = [
22
- "Flask>=2.0"
22
+ "Flask>=2.0",
23
+ "sqlalchemy>=2.0.0",
24
+ "requests",
25
+ "diskcache",
26
+ "geoip2"
23
27
  ]
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["."]
31
+ include = ["wafaHell*"]
32
+ exclude = ["venv_final*", "dist*", "tests*", "*.egg-info"]
@@ -0,0 +1,31 @@
1
+ from middleware import Wafahell
2
+ from flask import Flask, render_template, request
3
+ from werkzeug.middleware.proxy_fix import ProxyFix
4
+
5
+ app = Flask(__name__)
6
+
7
+
8
+ app.wsgi_app = ProxyFix(
9
+ app.wsgi_app,
10
+ x_for=1,
11
+ x_proto=1,
12
+ x_host=1,
13
+ x_port=1
14
+ )
15
+
16
+ @app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
17
+ def home():
18
+ return "<h1>Bem-vindo à minha aplicação Flask!</h1><p>Acesse /hello?nome=test</p>"
19
+
20
+ @app.route('/hello', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
21
+ def hello():
22
+ nome = request.args.get('nome', 'Visitante')
23
+ return f"<h1>Olá, {nome}!</h1>"
24
+
25
+ @app.route('/admin/dashboard')
26
+ def dashboard():
27
+ return "<h1>Dashboard Personalizado</h1><p>Este é o painel de controle personalizado.</p>"
28
+
29
+ if __name__ == '__main__':
30
+ Wafahell(app=app, dashboard_path='/hell/dashboard', block_durantion=15, rate_limit=True, block_ip=False)
31
+ app.run(debug=True, host='0.0.0.0', port=5001)
@@ -0,0 +1,5 @@
1
+ import os
2
+ from diskcache import Cache
3
+
4
+ cache_path = '/dev/shm/waf_cache' if os.path.exists('/dev/shm') else './waf_cache_temp'
5
+ waf_cache = Cache(cache_path)
@@ -0,0 +1,133 @@
1
+ from .model import WafLog, get_session
2
+ from datetime import datetime
3
+ import logging
4
+ import re
5
+ # Handler customizado para salvar no SQLite via SQLAlchemy
6
+ class SQLAlchemyHandler(logging.Handler):
7
+ def __init__(self):
8
+ super().__init__()
9
+ self.session = get_session()
10
+ # Regex para extrair dados da string formatada pelo parse_req
11
+ self.attr_pattern = re.compile(
12
+ r"Attack_type: (?P<type>.*?), IP: (?P<ip>.*?), .*?Path: (?P<path>.*?), Method: (?P<method>.*?), Payload: (?P<payload>.*?), attack_local: (?P<local>.*)"
13
+ )
14
+
15
+ def emit(self, record):
16
+ session = self.session # Certifique-se de instanciar a sessão
17
+ try:
18
+ msg = record.getMessage()
19
+
20
+ # Valores padrão
21
+ ip, path, method, payload, local, attack_type = (None, None, None, msg, None, "Info")
22
+
23
+ # Expandimos a condição para incluir [BLOCKED]
24
+ if any(tag in msg for tag in ["[ATTACK]", "[RATE LIMIT]", "[BLOCKED]"]):
25
+
26
+ def extract(key, text):
27
+ # Regex ajustada para capturar até a vírgula ou fim da tag, permitindo espaços internos
28
+ match = re.search(rf"{key}:?\s*([^,\]]+)", text)
29
+ return match.group(1).strip() if match else None
30
+
31
+ ip = extract("IP", msg)
32
+
33
+ if "[BLOCKED]" in msg:
34
+ attack_type = "IP BLOCK"
35
+ ua = extract("UA", msg)
36
+ payload = f"IP Bloqueado. User-Agent: {ua}" if ua else "IP Bloqueado"
37
+ path = "---"
38
+ method = "---"
39
+ elif "[RATE LIMIT]" in msg:
40
+ attack_type = "RATE LIMIT"
41
+ payload = "Exceeded request limit"
42
+ path = extract("Path", msg)
43
+ method = extract("Method", msg)
44
+
45
+ bucket = datetime.utcnow().strftime('%Y-%m-%d %H:%M')
46
+ log_entry = WafLog(
47
+ level=record.levelname,
48
+ attack_type=attack_type,
49
+ ip=ip,
50
+ path=path,
51
+ method=method,
52
+ payload=payload,
53
+ attack_local=local,
54
+ time_bucket=bucket
55
+ )
56
+
57
+ session.add(log_entry)
58
+ session.commit()
59
+ else:
60
+ # Lógica para [ATTACK]
61
+ attack_type = extract("Attack_type", msg) or "Unknown"
62
+ path = extract("Path", msg)
63
+ method = extract("Method", msg)
64
+
65
+ # Captura o local completo (ex: HEADER 'User-Agent')
66
+ local_match = re.search(r"attack_local:\s*(.+)$", msg)
67
+ local = local_match.group(1).strip() if local_match else extract("attack_local", msg)
68
+
69
+ # Regex específica para payloads que podem conter vírgulas
70
+ payload_match = re.search(r"Payload: (.*?), attack_local:", msg)
71
+ payload = payload_match.group(1) if payload_match else extract("Payload", msg)
72
+
73
+ if not "[RATE LIMIT]" in msg:
74
+ log_entry = WafLog(
75
+ level=record.levelname,
76
+ attack_type=attack_type,
77
+ ip=ip,
78
+ path=path,
79
+ method=method,
80
+ payload=payload,
81
+ attack_local=local,
82
+ # time_bucket fica None aqui para não filtrar ataques normais no banco
83
+ )
84
+
85
+ session.add(log_entry)
86
+ session.commit()
87
+
88
+ except Exception as e:
89
+ session.rollback()
90
+ finally:
91
+ session.close()
92
+
93
+ class Logger:
94
+ def __init__(self, name="WAF", log_file="waf.log", level=logging.INFO):
95
+ self.logger = logging.getLogger(name)
96
+ self.logger.setLevel(level)
97
+ self.logger.propagate = False
98
+
99
+ if not self.logger.handlers:
100
+ formatter = logging.Formatter(
101
+ "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
102
+ datefmt="%H:%M:%S - %d/%m/%Y"
103
+ )
104
+
105
+ # Handler 1: Console
106
+ console_handler = logging.StreamHandler()
107
+ console_handler.setFormatter(formatter)
108
+ self.logger.addHandler(console_handler)
109
+
110
+ # Handler 2: Arquivo
111
+ file_handler = logging.FileHandler(log_file)
112
+ file_handler.setFormatter(formatter)
113
+ self.logger.addHandler(file_handler)
114
+
115
+ # # Handler 3: Banco de Dados (A Mágica acontece aqui)
116
+ # db_handler = SQLAlchemyHandler()
117
+ # db_handler.setLevel(logging.INFO) # Salva INFO, WARNING e acima no DB
118
+ # self.logger.addHandler(db_handler)
119
+
120
+ def info(self, msg):
121
+ self.logger.info(msg)
122
+
123
+ def warning(self, msg):
124
+ self.logger.warning(msg)
125
+
126
+ def error(self, msg):
127
+ self.logger.error(msg)
128
+
129
+ def critical(self, msg):
130
+ self.logger.critical(msg)
131
+
132
+ def debug(self, msg):
133
+ self.logger.debug(msg)
@@ -0,0 +1,468 @@
1
+ from .model import Base, Blocked, WafLog, Whitelist, get_session, engine
2
+ from .logger import Logger
3
+ from .rateLimiter import RateLimiter
4
+ from .panel import setup_dashboard
5
+ from .utils import Admin, seed_default_whitelist
6
+ from .globals import waf_cache
7
+ from datetime import datetime, timedelta, timezone
8
+ import re
9
+ import time
10
+ from flask import request as req, abort, g
11
+ from urllib.parse import unquote
12
+ from sqlalchemy.exc import OperationalError
13
+ from sqlalchemy import text
14
+ import hashlib
15
+ import uuid
16
+ import socket
17
+ import ipaddress
18
+
19
+ # Inicializa o RateLimiter
20
+ limiter = RateLimiter(limit=100, window=60)
21
+
22
+ class Wafahell:
23
+ _instance = None
24
+
25
+ def __new__(cls, *args, **kwargs):
26
+ if not cls._instance:
27
+ cls._instance = super(Wafahell, cls).__new__(cls)
28
+ return cls._instance
29
+
30
+ def __init__(self, app=None, block_code=403, block_durantion=5, block_ip=False, log_func=None, monitor_mode=False, rate_limit=False, dashboard_path=None):
31
+ if not hasattr(self, 'initialized'):
32
+ self.initialized = True
33
+
34
+ self.app = app
35
+ self.block_code = block_code
36
+ self.log = log_func or Logger()
37
+ self.monitor_mode = monitor_mode
38
+ self.block_ip = block_ip
39
+ self.rate_limit = rate_limit
40
+ self.dashboard_path = dashboard_path
41
+ self.block_durantion = block_durantion
42
+ self.recent_blocks_cache = {}
43
+
44
+ self.rules_sqli = [
45
+ r"(\bUNION\b|\bSELECT\b|\bINSERT\b|\bDROP\b)",
46
+ r"' OR '1'='1"
47
+ ]
48
+ self.rules_xss = [
49
+ r"<script.*?>.*?</script>",
50
+ r"javascript:"
51
+ ]
52
+
53
+ if app is not None:
54
+ self.init_app(app)
55
+
56
+ def init_app(self, app):
57
+ Base.metadata.create_all(engine)
58
+ setup_dashboard(app, self.dashboard_path)
59
+ seed_default_whitelist()
60
+ Admin.create_admin_user(get_session())
61
+
62
+ if not app.secret_key:
63
+ def create_secret_key():
64
+ mac_address = str(uuid.getnode())
65
+ hostname = socket.gethostname()
66
+ project_salt = "wafahell-security-core-v1"
67
+ fingerprint = f"{mac_address}-{hostname}-{project_salt}"
68
+ return hashlib.sha256(fingerprint.encode()).hexdigest()
69
+ app.secret_key = create_secret_key()
70
+
71
+ @app.before_request
72
+ def create_session():
73
+ try:
74
+ req.session = get_session()
75
+ except Exception as e:
76
+ self.log.error(f"Erro ao criar sessão para requisição: {e}")
77
+ abort(self.block_code)
78
+
79
+ @app.teardown_request
80
+ def close_session(exc=None):
81
+ if hasattr(req, 'session'):
82
+ try:
83
+ req.session.close()
84
+ except Exception as e:
85
+ self.log.error(f"Erro ao fechar sessão: {e}")
86
+
87
+
88
+ @app.before_request
89
+ def waf_check():
90
+ if self.check_whitelist(req):
91
+ return
92
+ self.verify_client_blocked(req)
93
+ self.verify_rate_limit(req)
94
+ is_malicious, attack_local, payload, attack_type = self.is_malicious(req)
95
+
96
+ if not is_malicious:
97
+ return
98
+
99
+ self.log_attack(req, attack_type, payload, attack_local)
100
+
101
+ if not self.monitor_mode:
102
+ self.log.warning(self.parse_req(req, payload, attack_local, attack_type))
103
+ self.block_ip_address(req.remote_addr, req.headers.get("User-Agent", "unknown"))
104
+ abort(self.block_code)
105
+ else:
106
+ self.log.info(self.parse_req(req, payload, attack_local, attack_type))
107
+
108
+ @app.before_request
109
+ def start_timer():
110
+ g.waf_start_time = time.time()
111
+
112
+ @app.after_request
113
+ def stop_timer(response):
114
+ ignored_paths = [self.dashboard_path, f'{self.dashboard_path}/stats', '/static']
115
+
116
+ # 1. Ignora rotas do próprio painel para não sujar os logs e métricas
117
+ if any(req.path.startswith(path) for path in ignored_paths):
118
+ return response
119
+
120
+ # 2. LOG DE TRÁFEGO LEGÍTIMO
121
+ # Se o status_code for menor que 400, significa que o WAF não deu abort()
122
+ # e a requisição seguiu o fluxo normal.
123
+ if response.status_code != self.block_code:
124
+ self.log_legit_access(req)
125
+
126
+ # 3. RPS Logic (Bucketing by second)
127
+ current_timestamp = int(time.time())
128
+ rps_key = f"rps_{current_timestamp}"
129
+ waf_cache.incr(rps_key, default=0)
130
+ waf_cache.expire(rps_key, 10)
131
+
132
+ # 4. Latency Logic
133
+ if hasattr(g, 'waf_start_time'):
134
+ latency = (time.time() - g.waf_start_time) * 1000
135
+
136
+ # Exponential Moving Average
137
+ old_avg = waf_cache.get('latency_avg', default=0.0)
138
+ new_avg = (old_avg * 0.95) + (latency * 0.05) if old_avg > 0 else latency
139
+
140
+ waf_cache.set('latency_avg', new_avg, expire=3600)
141
+
142
+ return response
143
+
144
+ def log_legit_access(self, req):
145
+ entry = {
146
+ "timestamp": datetime.now(timezone.utc),
147
+ "attack_type": 'INFO',
148
+ "ip": req.remote_addr,
149
+ "path": req.path,
150
+ "method": req.method,
151
+ "level": 'INFO',
152
+ "payload": None, # INFO não tem payload
153
+ "attack_local": None # INFO não tem local de ataque
154
+ }
155
+ # Manda para o gerenciador de lote
156
+ self._push_to_batch(entry)
157
+
158
+ def log_attack(self, req, attack_type, payload, attack_local):
159
+ # Decodifica o payload para ficar legível no banco
160
+ safe_payload = unquote(payload) if payload else "---"
161
+
162
+ entry = {
163
+ "timestamp": datetime.now(timezone.utc),
164
+ "attack_type": attack_type, # Ex: SQLI, XSS
165
+ "ip": req.remote_addr,
166
+ "path": req.path,
167
+ "method": req.method,
168
+ "level": 'WARNING',
169
+ "payload": safe_payload,
170
+ "attack_local": attack_local # Ex: URL, BODY, HEADER
171
+ }
172
+ self.log_block(req)
173
+ self._push_to_batch(entry)
174
+
175
+ def log_block(self, req):
176
+ """
177
+ Registra especificamente bloqueios de conexão (Blacklist/Manual Block).
178
+ """
179
+ entry = {
180
+ "timestamp": datetime.now(timezone.utc),
181
+ "attack_type": "IP BLOCK",
182
+ "ip": req.remote_addr,
183
+ "path": req.path,
184
+ "method": req.method,
185
+ "level": 'INFO',
186
+ "payload": "---",
187
+ "attack_local": "WAF"
188
+ }
189
+
190
+ # Envia para o mesmo lote que os ataques normais
191
+ self._push_to_batch(entry)
192
+
193
+ def _push_to_batch(self, log_entry):
194
+ """
195
+ Função central que gerencia o Buffer de Logs (Memória -> Banco).
196
+ Usada tanto por logs legítimos quanto por ataques.
197
+ """
198
+ # 1. Recupera os logs pendentes do cache global
199
+ pending_logs = waf_cache.get('pending_logs_batch', default=[])
200
+ pending_logs.append(log_entry)
201
+
202
+ # 2. Verifica Gatilhos: 50 logs OU 3 segundos (reduzi de 10 pra 3 pra ficar mais "real time")
203
+ current_time = time.time()
204
+ last_flush = waf_cache.get('last_log_flush_time', default=0)
205
+
206
+ if len(pending_logs) >= 50 or (current_time - last_flush) > 3:
207
+
208
+ # Função interna de flush (Abre sessão dedicada para o lote)
209
+ session = get_session()
210
+ try:
211
+ # bulk_insert_mappings é OTIMIZADO para grandes volumes
212
+ session.bulk_insert_mappings(WafLog, pending_logs)
213
+ session.commit()
214
+
215
+ # Sucesso: Limpa o cache
216
+ waf_cache.set('pending_logs_batch', [], expire=60)
217
+ waf_cache.set('last_log_flush_time', current_time, expire=60)
218
+ except Exception as e:
219
+ session.rollback()
220
+ # Não usamos self.log.error aqui para não criar loop infinito se o erro for no logger
221
+ print(f" [ERRO CRÍTICO] Falha no Batch Insert do WAF: {e}")
222
+
223
+ # Mantém os dados no cache para tentar na próxima requisição
224
+ waf_cache.set('pending_logs_batch', pending_logs, expire=60)
225
+ finally:
226
+ session.close()
227
+ else:
228
+ # Apenas atualiza a lista no cache esperando o gatilho
229
+ waf_cache.set('pending_logs_batch', pending_logs, expire=60)
230
+
231
+ def detect_attack(self, data: str) -> bool:
232
+ for pattern in self.rules_xss:
233
+ if re.search(pattern, data, re.IGNORECASE):
234
+ return "XSS"
235
+ for pattern in self.rules_sqli:
236
+ if re.search(pattern, data, re.IGNORECASE):
237
+ return "SQLI"
238
+ return None
239
+
240
+ def is_malicious(self, req) -> tuple:
241
+ attack = self.detect_attack(req.base_url)
242
+ if attack:
243
+ print(f"[DEBUG] Attack detected in URL: {attack}")
244
+ return True, "URL", req.base_url, attack
245
+
246
+ for key, value in req.form.items():
247
+ attack = self.detect_attack(value)
248
+ if attack:
249
+ print(f"[DEBUG] Attack detected in FORM '{key}': {attack}")
250
+ return True, f"FORM '{key}'", value, attack
251
+
252
+ for key, value in req.args.items():
253
+ attack = self.detect_attack(value)
254
+ if attack:
255
+ print(f"[DEBUG] Attack detected in QUERY '{key}': {attack}")
256
+ return True, f"QUERY '{key}'", value, attack
257
+
258
+ for key, value in req.headers.items():
259
+ attack = self.detect_attack(value)
260
+ if attack:
261
+ print(f"[DEBUG] Attack detected in HEADER '{key}': {attack}")
262
+ return True, f"HEADER '{key}'", value, attack
263
+
264
+ if req.data:
265
+ body_content = req.data.decode(errors="ignore")
266
+ attack = self.detect_attack(body_content)
267
+ if attack:
268
+ print(f"[DEBUG] Attack detected in BODY: {attack}")
269
+ return True, "BODY", body_content, attack
270
+
271
+ if req.is_json:
272
+ json_data = req.get_json(silent=True)
273
+ if json_data:
274
+ import json
275
+ json_str = json.dumps(json_data)
276
+ attack = self.detect_attack(json_str)
277
+ if attack:
278
+ print(f"[DEBUG] Attack detected in JSON BODY: {attack}")
279
+ return True, "JSON BODY", json_str, attack
280
+
281
+ return False, None, None, None
282
+
283
+ def verify_client_blocked(self, req) -> None:
284
+ ip = req.remote_addr
285
+
286
+ # ---------------------------------------------------------
287
+ # 1. FAST PATH: Cache Check (Memória/Disco)
288
+ # ---------------------------------------------------------
289
+ # Se o cache diz que está bloqueado, abortamos imediatamente.
290
+ # Isso economiza 99% das queries de SELECT durante um ataque (fuzzing).
291
+ if waf_cache.get(f"blocked_{ip}"):
292
+ abort(self.block_code)
293
+
294
+ # ---------------------------------------------------------
295
+ # 2. SLOW PATH: Database Check
296
+ # ---------------------------------------------------------
297
+ # Só chegamos aqui se o IP não estiver no cache.
298
+ # Pode ser um IP limpo OU um IP bloqueado cujo cache expirou (TTL).
299
+
300
+ session = req.session # Reutiliza a sessão da request (Fundamental!)
301
+
302
+ try:
303
+ client_blocked = session.query(Blocked).filter_by(ip=ip).first()
304
+
305
+ if client_blocked:
306
+ # Normalização de fuso horário
307
+ now = datetime.now(timezone.utc) if client_blocked.blocked_until.tzinfo else datetime.utcnow()
308
+
309
+ # Caso 1: Ainda está bloqueado
310
+ if client_blocked.blocked_until > now:
311
+ # RE-AQUECIMENTO DO CACHE:
312
+ # O bloqueio ainda é válido no banco, então renovamos o cache por mais 60s.
313
+ # Assim, as próximas requisições desse IP vão cair no Fast Path acima.
314
+ waf_cache.set(f"blocked_{ip}", True, expire=60)
315
+ abort(self.block_code)
316
+
317
+ # Caso 2: O bloqueio expirou (Desbloqueio)
318
+ else:
319
+ try:
320
+ session.delete(client_blocked)
321
+ session.commit()
322
+ self.log.info(f"[UNBLOCKED] Bloqueio do IP {ip} expirou.")
323
+
324
+ # Garante que não sobrou lixo no cache
325
+ waf_cache.delete(f"blocked_{ip}")
326
+ # Remove travas de bloqueio antigas se existirem
327
+ waf_cache.delete(f"blocking_lock_{ip}")
328
+
329
+ except Exception as e:
330
+ # Se der erro de concorrência (outro thread já deletou), apenas ignora
331
+ session.rollback()
332
+
333
+ except OperationalError:
334
+ # Se o banco estiver travado/ocupado, abortamos por segurança (Fail Closed)
335
+ session.rollback()
336
+ abort(self.block_code)
337
+
338
+ def block_ip_address(self, ip, user_agent=None):
339
+ if not self.block_ip:
340
+ return
341
+
342
+ # 1. TRAVA DE CACHE (A Salvação do Fuzzing)
343
+ # Verifica se já existe um processo de bloqueio rodando para este IP.
344
+ # Isso impede que 50 threads do ffuf tentem fazer INSERT ao mesmo tempo.
345
+ cache_key = f"blocking_lock_{ip}"
346
+
347
+ if waf_cache.get(cache_key):
348
+ return # Já está sendo bloqueado por outra thread, aborta.
349
+
350
+ # Cria a trava por 5 segundos (tempo mais que suficiente para o insert ocorrer)
351
+ waf_cache.set(cache_key, True, expire=5)
352
+
353
+ # 2. REUTILIZAÇÃO DE SESSÃO
354
+ # Usamos a sessão que já está aberta na requisição atual.
355
+ session = req.session
356
+
357
+ try:
358
+ # Verifica se já existe na tabela (Query leve)
359
+ exists_block = session.query(Blocked).filter_by(ip=ip).first()
360
+
361
+ if not exists_block:
362
+ now = datetime.now(timezone.utc)
363
+ until = now + timedelta(minutes=self.block_durantion)
364
+
365
+ # Atenção ao formato do blocked_at se o seu Model esperar String
366
+ # Se no model for DateTime, use 'now'. Se for String, use 'now.strftime...'
367
+ new_block = Blocked(
368
+ ip=ip,
369
+ user_agent=user_agent or "unknown",
370
+ blocked_at=now.strftime("%H:%M:%S"),
371
+ blocked_until=until
372
+ )
373
+
374
+ session.add(new_block)
375
+ session.commit()
376
+
377
+ # Log no arquivo/console
378
+ self.log.warning(f"[BLOCKED] IP: {ip} bloqueado por {self.block_durantion} min.")
379
+
380
+ # 3. PRÉ-AQUECIMENTO DE CACHE
381
+ # Já avisa o cache que este IP está bloqueado.
382
+ # A próxima requisição vai bater no verify_client_blocked, ler o cache e ser barrada sem tocar no banco.
383
+ waf_cache.set(f"blocked_{ip}", True, expire=60)
384
+
385
+ except Exception as e:
386
+ session.rollback()
387
+ self.log.error(f"Erro ao persistir bloqueio: {e}")
388
+ # Se deu erro, removemos a trava para tentar novamente na próxima
389
+ waf_cache.delete(cache_key)
390
+
391
+ # IMPORTANTE:
392
+ # Não usamos 'finally: session.close()' aqui!
393
+ # Quem fecha é o @app.teardown_request no final do ciclo.
394
+
395
+ def verify_rate_limit(self, req) -> None:
396
+ if self.rate_limit:
397
+ ip = req.remote_addr
398
+ ua = req.headers.get("User-Agent", "unknown")
399
+
400
+ if limiter.is_rate_limited(ip, ua):
401
+ self.log_attack(
402
+ req=req,
403
+ attack_type="RATE LIMIT",
404
+ payload="Too Many Requests",
405
+ attack_local="Rate Limiter"
406
+ )
407
+
408
+ self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
409
+
410
+ if self.monitor_mode:
411
+ return
412
+
413
+ if self.block_ip:
414
+ self.block_ip_address(ip, ua)
415
+ abort(self.block_code)
416
+
417
+
418
+ def check_whitelist(self, req) -> bool:
419
+ ip_str = req.remote_addr
420
+
421
+ # 1. CACHE (Velocidade Extrema)
422
+ # Se esse IP já foi validado antes (seja por faixa ou exato), libera.
423
+ if waf_cache.get(f"whitelist_{ip_str}"):
424
+ return True
425
+
426
+ session = req.session
427
+ try:
428
+ # 2. Busca Exata (Para IPs unitários como 8.8.8.8)
429
+ # É muito rápido.
430
+ if session.query(Whitelist).filter_by(ip=ip_str).first():
431
+ waf_cache.set(f"whitelist_{ip_str}", True, expire=3600)
432
+ return True
433
+
434
+ # 3. Busca por Faixas (CIDR)
435
+ # Só executamos isso se não achou match exato.
436
+ # Trazemos apenas as faixas que contêm "/" para não trazer IPs soltos
437
+ cidr_ranges = session.query(Whitelist.ip).filter(Whitelist.ip.like('%/%')).all()
438
+
439
+ if not cidr_ranges:
440
+ return False
441
+
442
+ user_ip = ipaddress.ip_address(ip_str)
443
+
444
+ for row in cidr_ranges:
445
+ try:
446
+ # Verifica matematicamente se o IP está na rede
447
+ network = ipaddress.ip_network(row.ip, strict=False)
448
+ if user_ip in network:
449
+ # ACHOU!
450
+ # Salva o IP DO USUÁRIO no cache.
451
+ # Na próxima requisição, ele cai no passo 1 e nem passa por aqui.
452
+ waf_cache.set(f"whitelist_{ip_str}", True, expire=3600)
453
+ return True
454
+ except ValueError:
455
+ continue
456
+
457
+ except Exception as e:
458
+ return False
459
+
460
+ return False
461
+
462
+ def parse_req(self, req, payload, attack_local=None, attack_type=None) -> str:
463
+ ip = req.remote_addr
464
+ user_agent = req.headers.get("User-Agent", "unknown")
465
+ path = req.path
466
+ method = req.method
467
+ attack_local = attack_local or "unknown"
468
+ return f"[ATTACK] Attack_type: {attack_type}, IP: {ip}, User-Agent: {user_agent}, Path: {path}, Method: {method}, Payload: {unquote(payload)}, attack_local: {attack_local}"