wafaHell 0.1.1__tar.gz → 1.0.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.
- {wafahell-0.1.1 → wafahell-1.0.0}/PKG-INFO +8 -4
- {wafahell-0.1.1 → wafahell-1.0.0}/README.md +3 -3
- {wafahell-0.1.1 → wafahell-1.0.0}/pyproject.toml +6 -2
- wafahell-1.0.0/wafaHell/__init__.py +3 -0
- wafahell-1.0.0/wafaHell/app.py +31 -0
- wafahell-1.0.0/wafaHell/globals.py +5 -0
- wafahell-1.0.0/wafaHell/logger.py +132 -0
- wafahell-1.0.0/wafaHell/middleware.py +351 -0
- wafahell-1.0.0/wafaHell/mock.py +53 -0
- wafahell-1.0.0/wafaHell/model.py +100 -0
- wafahell-1.0.0/wafaHell/panel.py +192 -0
- wafahell-1.0.0/wafaHell/rateLimiter.py +20 -0
- wafahell-1.0.0/wafaHell/utils.py +312 -0
- {wafahell-0.1.1 → wafahell-1.0.0}/wafaHell.egg-info/PKG-INFO +8 -4
- {wafahell-0.1.1 → wafahell-1.0.0}/wafaHell.egg-info/SOURCES.txt +8 -0
- wafahell-1.0.0/wafaHell.egg-info/requires.txt +5 -0
- wafahell-0.1.1/wafaHell/__init__.py +0 -3
- wafahell-0.1.1/wafaHell/middleware.py +0 -65
- wafahell-0.1.1/wafaHell.egg-info/requires.txt +0 -1
- {wafahell-0.1.1 → wafahell-1.0.0}/LICENSE +0 -0
- {wafahell-0.1.1 → wafahell-1.0.0}/setup.cfg +0 -0
- {wafahell-0.1.1 → wafahell-1.0.0}/wafaHell.egg-info/dependency_links.txt +0 -0
- {wafahell-0.1.1 → wafahell-1.0.0}/wafaHell.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wafaHell
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.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
|
|
@@ -42,14 +46,14 @@ Middleware WAF for Flask, to detect SQLi and XSS.
|
|
|
42
46
|
## Instalation
|
|
43
47
|
|
|
44
48
|
```bash
|
|
45
|
-
pip install
|
|
49
|
+
pip install wafahell
|
|
46
50
|
```
|
|
47
51
|
|
|
48
52
|
## Usage
|
|
49
53
|
```python
|
|
50
54
|
from flask import Flask
|
|
51
|
-
from
|
|
55
|
+
from wafahell import WafaHell
|
|
52
56
|
|
|
53
57
|
app = Flask(__name__)
|
|
54
|
-
waf =
|
|
58
|
+
waf = WafaHell(app)
|
|
55
59
|
```
|
|
@@ -5,14 +5,14 @@ Middleware WAF for Flask, to detect SQLi and XSS.
|
|
|
5
5
|
## Instalation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
pip install
|
|
8
|
+
pip install wafahell
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
```python
|
|
13
13
|
from flask import Flask
|
|
14
|
-
from
|
|
14
|
+
from wafahell import WafaHell
|
|
15
15
|
|
|
16
16
|
app = Flask(__name__)
|
|
17
|
-
waf =
|
|
17
|
+
waf = WafaHell(app)
|
|
18
18
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wafaHell"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "1.0.0"
|
|
8
8
|
description = "Middleware WAF to Flask"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -19,5 +19,9 @@ 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
|
]
|
|
@@ -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, dashboard_path='/hell/dashboard', block_durantion=1, rate_limit=True, block_ip=True)
|
|
31
|
+
app.run(debug=True, host='0.0.0.0', port=5000)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import logging
|
|
3
|
+
from model import WafLog, get_session
|
|
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
|
+
|
|
98
|
+
if not self.logger.handlers:
|
|
99
|
+
formatter = logging.Formatter(
|
|
100
|
+
"[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
|
|
101
|
+
datefmt="%H:%M:%S - %d/%m/%Y"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Handler 1: Console
|
|
105
|
+
console_handler = logging.StreamHandler()
|
|
106
|
+
console_handler.setFormatter(formatter)
|
|
107
|
+
self.logger.addHandler(console_handler)
|
|
108
|
+
|
|
109
|
+
# Handler 2: Arquivo
|
|
110
|
+
file_handler = logging.FileHandler(log_file)
|
|
111
|
+
file_handler.setFormatter(formatter)
|
|
112
|
+
self.logger.addHandler(file_handler)
|
|
113
|
+
|
|
114
|
+
# Handler 3: Banco de Dados (A Mágica acontece aqui)
|
|
115
|
+
db_handler = SQLAlchemyHandler()
|
|
116
|
+
db_handler.setLevel(logging.INFO) # Salva INFO, WARNING e acima no DB
|
|
117
|
+
self.logger.addHandler(db_handler)
|
|
118
|
+
|
|
119
|
+
def info(self, msg):
|
|
120
|
+
self.logger.info(msg)
|
|
121
|
+
|
|
122
|
+
def warning(self, msg):
|
|
123
|
+
self.logger.warning(msg)
|
|
124
|
+
|
|
125
|
+
def error(self, msg):
|
|
126
|
+
self.logger.error(msg)
|
|
127
|
+
|
|
128
|
+
def critical(self, msg):
|
|
129
|
+
self.logger.critical(msg)
|
|
130
|
+
|
|
131
|
+
def debug(self, msg):
|
|
132
|
+
self.logger.debug(msg)
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from flask import request as req, abort, g
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
from model import Base, Blocked, WafLog, get_session, engine
|
|
9
|
+
from logger import Logger
|
|
10
|
+
from rateLimiter import RateLimiter
|
|
11
|
+
from sqlalchemy.exc import OperationalError
|
|
12
|
+
from panel import setup_dashboard
|
|
13
|
+
from utils import Admin
|
|
14
|
+
from sqlalchemy import text
|
|
15
|
+
from globals import waf_cache
|
|
16
|
+
|
|
17
|
+
# Inicializa o RateLimiter
|
|
18
|
+
limiter = RateLimiter(limit=100, window=60)
|
|
19
|
+
|
|
20
|
+
class WafaHell:
|
|
21
|
+
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):
|
|
22
|
+
self.app = app
|
|
23
|
+
self.block_code = block_code
|
|
24
|
+
self.log = log_func or Logger()
|
|
25
|
+
self.monitor_mode = monitor_mode
|
|
26
|
+
self.block_ip = block_ip
|
|
27
|
+
self.rate_limit = rate_limit
|
|
28
|
+
self.dashboard_path = dashboard_path
|
|
29
|
+
self.block_durantion = block_durantion
|
|
30
|
+
self.recent_blocks_cache = {}
|
|
31
|
+
|
|
32
|
+
self.rules_sqli = [
|
|
33
|
+
r"(\bUNION\b|\bSELECT\b|\bINSERT\b|\bDROP\b)",
|
|
34
|
+
r"' OR '1'='1"
|
|
35
|
+
]
|
|
36
|
+
self.rules_xss = [
|
|
37
|
+
r"<script.*?>.*?</script>",
|
|
38
|
+
r"javascript:"
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
if app is not None:
|
|
42
|
+
self.init_app(app)
|
|
43
|
+
|
|
44
|
+
def init_app(self, app):
|
|
45
|
+
Base.metadata.create_all(engine)
|
|
46
|
+
setup_dashboard(app, self.dashboard_path)
|
|
47
|
+
Admin.create_admin_user(get_session())
|
|
48
|
+
|
|
49
|
+
if not app.secret_key:
|
|
50
|
+
app.secret_key = os.urandom(24)
|
|
51
|
+
|
|
52
|
+
@app.before_request
|
|
53
|
+
def create_session():
|
|
54
|
+
try:
|
|
55
|
+
req.session = get_session()
|
|
56
|
+
except Exception as e:
|
|
57
|
+
self.log.error(f"Erro ao criar sessão para requisição: {e}")
|
|
58
|
+
abort(self.block_code)
|
|
59
|
+
|
|
60
|
+
@app.teardown_request
|
|
61
|
+
def close_session(exc=None):
|
|
62
|
+
if hasattr(req, 'session'):
|
|
63
|
+
try:
|
|
64
|
+
req.session.close()
|
|
65
|
+
except Exception as e:
|
|
66
|
+
self.log.error(f"Erro ao fechar sessão: {e}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.before_request
|
|
70
|
+
def waf_check():
|
|
71
|
+
self.verify_client_blocked(req)
|
|
72
|
+
self.verify_rate_limit(req)
|
|
73
|
+
is_malicious, attack_local, payload, attack_type = self.is_malicious(req)
|
|
74
|
+
|
|
75
|
+
if not is_malicious:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if not self.monitor_mode:
|
|
79
|
+
self.log.warning(self.parse_req(req, payload, attack_local, attack_type))
|
|
80
|
+
self.block_ip_address(req.remote_addr, req.headers.get("User-Agent", "unknown"))
|
|
81
|
+
abort(self.block_code)
|
|
82
|
+
else:
|
|
83
|
+
self.log.info(self.parse_req(req, payload, attack_local, attack_type))
|
|
84
|
+
|
|
85
|
+
@app.before_request
|
|
86
|
+
def start_timer():
|
|
87
|
+
g.waf_start_time = time.time()
|
|
88
|
+
|
|
89
|
+
@app.after_request
|
|
90
|
+
def stop_timer(response):
|
|
91
|
+
ignored_paths = [self.dashboard_path, f'{self.dashboard_path}/stats', '/static']
|
|
92
|
+
|
|
93
|
+
# 1. Ignora rotas do próprio painel para não sujar os logs e métricas
|
|
94
|
+
if any(req.path.startswith(path) for path in ignored_paths):
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
# 2. LOG DE TRÁFEGO LEGÍTIMO
|
|
98
|
+
# Se o status_code for menor que 400, significa que o WAF não deu abort()
|
|
99
|
+
# e a requisição seguiu o fluxo normal.
|
|
100
|
+
if response.status_code != self.block_code:
|
|
101
|
+
self.log_legit_access(req)
|
|
102
|
+
|
|
103
|
+
# 3. RPS Logic (Bucketing by second)
|
|
104
|
+
current_timestamp = int(time.time())
|
|
105
|
+
rps_key = f"rps_{current_timestamp}"
|
|
106
|
+
waf_cache.incr(rps_key, default=0)
|
|
107
|
+
waf_cache.expire(rps_key, 10)
|
|
108
|
+
|
|
109
|
+
# 4. Latency Logic
|
|
110
|
+
if hasattr(g, 'waf_start_time'):
|
|
111
|
+
latency = (time.time() - g.waf_start_time) * 1000
|
|
112
|
+
|
|
113
|
+
# Exponential Moving Average
|
|
114
|
+
old_avg = waf_cache.get('latency_avg', default=0.0)
|
|
115
|
+
new_avg = (old_avg * 0.95) + (latency * 0.05) if old_avg > 0 else latency
|
|
116
|
+
|
|
117
|
+
waf_cache.set('latency_avg', new_avg, expire=3600)
|
|
118
|
+
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
def log_legit_access(self, req):
|
|
122
|
+
session = get_session()
|
|
123
|
+
try:
|
|
124
|
+
new_log = WafLog(
|
|
125
|
+
timestamp=datetime.now(timezone.utc),
|
|
126
|
+
attack_type='INFO', # Identificador de tráfego limpo
|
|
127
|
+
ip=req.remote_addr,
|
|
128
|
+
path=req.path,
|
|
129
|
+
method=req.method,
|
|
130
|
+
level='INFO'
|
|
131
|
+
)
|
|
132
|
+
session.add(new_log)
|
|
133
|
+
session.commit()
|
|
134
|
+
except Exception as e:
|
|
135
|
+
session.rollback()
|
|
136
|
+
self.log.error(f"Error logging legit access: {e}")
|
|
137
|
+
finally:
|
|
138
|
+
session.close()
|
|
139
|
+
|
|
140
|
+
def detect_attack(self, data: str) -> bool:
|
|
141
|
+
for pattern in self.rules_xss:
|
|
142
|
+
if re.search(pattern, data, re.IGNORECASE):
|
|
143
|
+
return "XSS"
|
|
144
|
+
for pattern in self.rules_sqli:
|
|
145
|
+
if re.search(pattern, data, re.IGNORECASE):
|
|
146
|
+
return "SQLI"
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def is_malicious(self, req) -> tuple:
|
|
150
|
+
attack = self.detect_attack(req.base_url)
|
|
151
|
+
if attack:
|
|
152
|
+
print(f"[DEBUG] Attack detected in URL: {attack}")
|
|
153
|
+
return True, "URL", req.base_url, attack
|
|
154
|
+
|
|
155
|
+
for key, value in req.form.items():
|
|
156
|
+
attack = self.detect_attack(value)
|
|
157
|
+
if attack:
|
|
158
|
+
print(f"[DEBUG] Attack detected in FORM '{key}': {attack}")
|
|
159
|
+
return True, f"FORM '{key}'", value, attack
|
|
160
|
+
|
|
161
|
+
for key, value in req.args.items():
|
|
162
|
+
attack = self.detect_attack(value)
|
|
163
|
+
if attack:
|
|
164
|
+
print(f"[DEBUG] Attack detected in QUERY '{key}': {attack}")
|
|
165
|
+
return True, f"QUERY '{key}'", value, attack
|
|
166
|
+
|
|
167
|
+
for key, value in req.headers.items():
|
|
168
|
+
attack = self.detect_attack(value)
|
|
169
|
+
if attack:
|
|
170
|
+
print(f"[DEBUG] Attack detected in HEADER '{key}': {attack}")
|
|
171
|
+
return True, f"HEADER '{key}'", value, attack
|
|
172
|
+
|
|
173
|
+
if req.data:
|
|
174
|
+
body_content = req.data.decode(errors="ignore")
|
|
175
|
+
attack = self.detect_attack(body_content)
|
|
176
|
+
if attack:
|
|
177
|
+
print(f"[DEBUG] Attack detected in BODY: {attack}")
|
|
178
|
+
return True, "BODY", body_content, attack
|
|
179
|
+
|
|
180
|
+
if req.is_json:
|
|
181
|
+
json_data = req.get_json(silent=True)
|
|
182
|
+
if json_data:
|
|
183
|
+
import json
|
|
184
|
+
json_str = json.dumps(json_data)
|
|
185
|
+
attack = self.detect_attack(json_str)
|
|
186
|
+
if attack:
|
|
187
|
+
print(f"[DEBUG] Attack detected in JSON BODY: {attack}")
|
|
188
|
+
return True, "JSON BODY", json_str, attack
|
|
189
|
+
|
|
190
|
+
return False, None, None, None
|
|
191
|
+
|
|
192
|
+
def verify_client_blocked(self, req) -> None:
|
|
193
|
+
session = req.session
|
|
194
|
+
try:
|
|
195
|
+
client_blocked = session.query(Blocked).filter_by(
|
|
196
|
+
ip=req.remote_addr,
|
|
197
|
+
).first()
|
|
198
|
+
|
|
199
|
+
if client_blocked:
|
|
200
|
+
|
|
201
|
+
now = datetime.now(timezone.utc) if client_blocked.blocked_until.tzinfo else datetime.utcnow()
|
|
202
|
+
|
|
203
|
+
if client_blocked.blocked_until <= now:
|
|
204
|
+
# --- TRAVA DE DESBLOQUEIO (Anti-Race Condition) ---
|
|
205
|
+
# Usamos um marcador no cache para saber se alguém já está desbloqueando este IP
|
|
206
|
+
cache_key = f"unblocking_{req.remote_addr}"
|
|
207
|
+
if cache_key in self.recent_blocks_cache:
|
|
208
|
+
return # Outra thread já está limpando este IP, apenas saia
|
|
209
|
+
|
|
210
|
+
self.recent_blocks_cache[cache_key] = True
|
|
211
|
+
# --------------------------------------------------
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
session.delete(client_blocked)
|
|
215
|
+
session.commit()
|
|
216
|
+
|
|
217
|
+
# Limpa os caches de controle deste IP
|
|
218
|
+
self.recent_blocks_cache.pop(req.remote_addr, None)
|
|
219
|
+
self.recent_blocks_cache.pop(cache_key, None)
|
|
220
|
+
|
|
221
|
+
self.log.info(f"[UNBLOCKED] IP {req.remote_addr} bloqueio expirou.")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
session.rollback()
|
|
224
|
+
self.recent_blocks_cache.pop(cache_key, None)
|
|
225
|
+
raise e
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# Se chegou aqui, ainda está bloqueado
|
|
229
|
+
abort(self.block_code)
|
|
230
|
+
|
|
231
|
+
except OperationalError:
|
|
232
|
+
session.rollback()
|
|
233
|
+
abort(self.block_code)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
|
|
237
|
+
client_blocked = session.query(Blocked).filter_by(
|
|
238
|
+
ip=req.remote_addr,
|
|
239
|
+
user_agent=req.headers.get("User-Agent")
|
|
240
|
+
).first()
|
|
241
|
+
|
|
242
|
+
if not client_blocked:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
# Normaliza o tempo para comparação
|
|
246
|
+
now = datetime.now(timezone.utc) if client_blocked.blocked_until.tzinfo else datetime.utcnow()
|
|
247
|
+
|
|
248
|
+
if client_blocked.blocked_until <= now:
|
|
249
|
+
# --- TRAVA DE DESBLOQUEIO (Anti-Race Condition) ---
|
|
250
|
+
# Usamos um marcador no cache para saber se alguém já está desbloqueando este IP
|
|
251
|
+
cache_key = f"unblocking_{req.remote_addr}"
|
|
252
|
+
if cache_key in self.recent_blocks_cache:
|
|
253
|
+
return # Outra thread já está limpando este IP, apenas saia
|
|
254
|
+
|
|
255
|
+
self.recent_blocks_cache[cache_key] = True
|
|
256
|
+
# --------------------------------------------------
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
session.delete(client_blocked)
|
|
260
|
+
session.commit()
|
|
261
|
+
|
|
262
|
+
# Limpa os caches de controle deste IP
|
|
263
|
+
self.recent_blocks_cache.pop(req.remote_addr, None)
|
|
264
|
+
self.recent_blocks_cache.pop(cache_key, None)
|
|
265
|
+
|
|
266
|
+
self.log.info(f"[UNBLOCKED] IP {req.remote_addr} bloqueio expirou.")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
session.rollback()
|
|
269
|
+
self.recent_blocks_cache.pop(cache_key, None)
|
|
270
|
+
raise e
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
abort(self.block_code)
|
|
274
|
+
|
|
275
|
+
except OperationalError:
|
|
276
|
+
session.rollback()
|
|
277
|
+
abort(self.block_code)
|
|
278
|
+
|
|
279
|
+
def block_ip_address(self, ip, user_agent=None):
|
|
280
|
+
if not self.block_ip:
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# 1. Trava de Memória (ajuda, mas não resolve 100% em multi-processo)
|
|
284
|
+
now_ts = datetime.now().timestamp()
|
|
285
|
+
if ip in self.recent_blocks_cache:
|
|
286
|
+
if now_ts - self.recent_blocks_cache[ip] < 5:
|
|
287
|
+
return
|
|
288
|
+
self.recent_blocks_cache[ip] = now_ts
|
|
289
|
+
|
|
290
|
+
session = get_session()
|
|
291
|
+
try:
|
|
292
|
+
# 2. TRAVA DE BANCO: Verifica se já houve um log desse IP nos últimos 2 segundos
|
|
293
|
+
# Isso evita que as 6 threads do ffuf que passaram pela trava de memória gravem no banco
|
|
294
|
+
|
|
295
|
+
time_threshold = datetime.now(timezone.utc) - timedelta(seconds=2)
|
|
296
|
+
|
|
297
|
+
# Buscamos na tabela de LOGS (WafLog) se já existe um registro recente
|
|
298
|
+
|
|
299
|
+
exists_recent_log = session.query(WafLog).filter(
|
|
300
|
+
WafLog.ip == ip,
|
|
301
|
+
WafLog.attack_type.in_(['RATE LIMIT', 'IP BLOCK']),
|
|
302
|
+
WafLog.timestamp >= time_threshold
|
|
303
|
+
).first()
|
|
304
|
+
|
|
305
|
+
if not exists_recent_log:
|
|
306
|
+
# Só prossegue se não houver log recente
|
|
307
|
+
exists_block = session.query(Blocked).filter_by(ip=ip).first()
|
|
308
|
+
if not exists_block:
|
|
309
|
+
now = datetime.now(timezone.utc)
|
|
310
|
+
until = now + timedelta(minutes=self.block_durantion)
|
|
311
|
+
|
|
312
|
+
new_block = Blocked(
|
|
313
|
+
ip=ip, user_agent=user_agent,
|
|
314
|
+
blocked_at=now, blocked_until=until
|
|
315
|
+
)
|
|
316
|
+
session.add(new_block)
|
|
317
|
+
session.commit()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
except Exception as e:
|
|
322
|
+
session.rollback()
|
|
323
|
+
self.log.error(f"Erro ao persistir bloqueio: {e}")
|
|
324
|
+
finally:
|
|
325
|
+
session.close()
|
|
326
|
+
|
|
327
|
+
def verify_rate_limit(self, req) -> None:
|
|
328
|
+
if self.rate_limit:
|
|
329
|
+
ip = req.remote_addr
|
|
330
|
+
ua = req.headers.get("User-Agent", "unknown")
|
|
331
|
+
|
|
332
|
+
if limiter.is_rate_limited(ip, ua):
|
|
333
|
+
if self.monitor_mode:
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if self.block_ip:
|
|
338
|
+
self.block_ip_address(ip, ua)
|
|
339
|
+
self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
|
|
340
|
+
self.log.warning(f"[BLOCKED] IP: {ip}, UA: {ua}")
|
|
341
|
+
abort(self.block_code)
|
|
342
|
+
|
|
343
|
+
self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
|
|
344
|
+
|
|
345
|
+
def parse_req(self, req, payload, attack_local=None, attack_type=None) -> str:
|
|
346
|
+
ip = req.remote_addr
|
|
347
|
+
user_agent = req.headers.get("User-Agent", "unknown")
|
|
348
|
+
path = req.path
|
|
349
|
+
method = req.method
|
|
350
|
+
attack_local = attack_local or "unknown"
|
|
351
|
+
return f"[ATTACK] Attack_type: {attack_type}, IP: {ip}, User-Agent: {user_agent}, Path: {path}, Method: {method}, Payload: {unquote(payload)}, attack_local: {attack_local}"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
# Configuração do alvo
|
|
4
|
+
BASE_URL = "http://127.0.0.1:5000/hello"
|
|
5
|
+
|
|
6
|
+
# Definição dos payloads de teste (SQLi e XSS clássicos)
|
|
7
|
+
payloads = {
|
|
8
|
+
"URL Query": {"url": f"{BASE_URL}?id=1' OR '1'='1"},
|
|
9
|
+
"Form Data": {"method": "POST", "data": {"user": "<script>alert(1)</script>"}},
|
|
10
|
+
"JSON Body": {"method": "POST", "json": {"search": "SELECT * FROM users"}},
|
|
11
|
+
"Cookies": {"method": "POST", "cookies": {"session": "UNION SELECT NULL,NULL--"}},
|
|
12
|
+
"Custom Header": {"method": "POST", "headers": {"User-Agent": "'; DROP TABLE logs;--"}},
|
|
13
|
+
"Multipart File": {"method": "POST", "files": {"file": ("README.md", "payload: <script>alert('XSS')</script>")}},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
def run_mock():
|
|
17
|
+
print(f"🚀 Iniciando Mock de Testes WafaHell no alvo: {BASE_URL}\n")
|
|
18
|
+
print(f"{'VETOR DE TESTE':<20} | {'STATUS':<10} | {'RESULTADO'}")
|
|
19
|
+
print("-" * 55)
|
|
20
|
+
|
|
21
|
+
for test_name, config in payloads.items():
|
|
22
|
+
try:
|
|
23
|
+
# Prepara a requisição
|
|
24
|
+
url = config.get("url", BASE_URL)
|
|
25
|
+
method = config.get("method", "GET")
|
|
26
|
+
|
|
27
|
+
# Executa a requisição com os parâmetros dinâmicos
|
|
28
|
+
response = requests.request(
|
|
29
|
+
method=method,
|
|
30
|
+
url=url,
|
|
31
|
+
data=config.get("data"),
|
|
32
|
+
json=config.get("json"),
|
|
33
|
+
cookies=config.get("cookies"),
|
|
34
|
+
headers=config.get("headers"),
|
|
35
|
+
files=config.get("files"),
|
|
36
|
+
timeout=5
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Validação: 403 significa que o WAF bloqueou (Sucesso no teste)
|
|
40
|
+
if response.status_code == 403:
|
|
41
|
+
status_txt = "✅ BLOQUEADO"
|
|
42
|
+
result_txt = "Sucesso (WAF Ativo)"
|
|
43
|
+
else:
|
|
44
|
+
status_txt = f"❌ {response.status_code}"
|
|
45
|
+
result_txt = "Falha (Vulnerável!)"
|
|
46
|
+
|
|
47
|
+
print(f"{test_name:<20} | {status_txt:<10} | {result_txt}")
|
|
48
|
+
|
|
49
|
+
except Exception as e:
|
|
50
|
+
print(f"{test_name:<20} | ⚠️ ERRO | {str(e)}")
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
run_mock()
|