sync: 181 file da Baida98/AI@80e22d8c (2026-08-25 12:01 UTC) [deploy-all]

#81
by Baida07 - opened
Files changed (2) hide show
  1. api/auth_guard.py +10 -5
  2. tests/test_security.py +26 -0
api/auth_guard.py CHANGED
@@ -124,15 +124,20 @@ def _prune_expired_rate_keys(now: float, window_s: float) -> None:
124
  def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
125
  """Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
126
 
127
- GAP-AUTH-FIX: USER (role=0) usa client_ip come discriminante bucket per IP,
128
- non bucket globale condiviso. Previene DoS a costo zero (un client svuota tutti).
129
- Ruoli autenticati (MACHINE/OPERATOR/ADMIN) continuano a usare il token hash.
 
 
130
  """
131
  if role == 0:
132
- # USER: discrimina per IP ogni client ha il proprio bucket
133
  raw = f"0:{client_ip or 'unknown'}"
 
 
 
134
  else:
135
- # Ruoli autenticati: discrimina per token (più preciso dell'IP)
136
  raw = f"{role}:{token_header or 'anonymous'}"
137
  return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
138
 
 
124
  def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
125
  """Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
126
 
127
+ USER usa sempre l'IP come discriminante. MACHINE usa l'IP quando il proxy
128
+ fidato lo inoltra: Cloudflare usa un unico token interno per tutti i browser,
129
+ quindi il solo token renderebbe globale il limite di 30 richieste/minuto.
130
+ In assenza di IP attestato, MACHINE conserva il fallback per-token. OPERATOR
131
+ e ADMIN mantengono il bucket per-token.
132
  """
133
  if role == 0:
134
+ # USER: bucket per IP, mai globale condiviso.
135
  raw = f"0:{client_ip or 'unknown'}"
136
+ elif role == 1 and client_ip:
137
+ # MACHINE via proxy fidato: separa gli utenti dietro INTERNAL_TOKEN.
138
+ raw = f"1:{client_ip}"
139
  else:
140
+ # Chiamate server-to-server e ruoli elevati: bucket per token.
141
  raw = f"{role}:{token_header or 'anonymous'}"
142
  return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
143
 
tests/test_security.py CHANGED
@@ -132,6 +132,32 @@ class TestRequireRole(unittest.TestCase):
132
  _run(dep(req_mock, resolved=self.AR.USER))
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  class TestBrowserEndpointAuth(unittest.TestCase):
136
  """SEC-5: endpoint browser stateless non fail-open (GAP-1-fix)."""
137
 
 
132
  _run(dep(req_mock, resolved=self.AR.USER))
133
 
134
 
135
+ class TestMachineRateLimitClientBuckets(unittest.TestCase):
136
+ """SEC-3B: il token proxy MACHINE non deve condividere un unico bucket pubblico."""
137
+
138
+ def setUp(self):
139
+ try:
140
+ from api.auth_guard import _rate_key
141
+ self.rate_key = _rate_key
142
+ except ImportError as e:
143
+ self.skipTest(str(e))
144
+
145
+ def test_machine_uses_distinct_attested_client_ips(self):
146
+ first = self.rate_key(1, "shared-internal-token", "198.51.100.10")
147
+ second = self.rate_key(1, "shared-internal-token", "198.51.100.11")
148
+ self.assertNotEqual(first, second)
149
+
150
+ def test_machine_without_attested_ip_keeps_token_bucket(self):
151
+ first = self.rate_key(1, "shared-internal-token", None)
152
+ second = self.rate_key(1, "shared-internal-token", None)
153
+ self.assertEqual(first, second)
154
+
155
+ def test_operator_remains_token_scoped(self):
156
+ first = self.rate_key(2, "operator-token", "198.51.100.10")
157
+ second = self.rate_key(2, "operator-token", "198.51.100.11")
158
+ self.assertEqual(first, second)
159
+
160
+
161
  class TestBrowserEndpointAuth(unittest.TestCase):
162
  """SEC-5: endpoint browser stateless non fail-open (GAP-1-fix)."""
163