abinazebinoy commited on
Commit
0933db5
·
unverified ·
2 Parent(s): 0ca8d0b36f93c8

Merge pull request #162 from abinaze/fix/webhook-ssrf-and-key-rotation

Browse files
backend/services/api_key_manager.py CHANGED
@@ -9,6 +9,7 @@ Roles:
9
  admin - all methods including key management
10
  """
11
  import json
 
12
  import uuid
13
  import hashlib
14
  import secrets
@@ -74,6 +75,43 @@ def _save_key(entry: Dict[str, Any]) -> None:
74
  logger.error(f"Failed to save API key: {e}")
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  def create_key(name: str, role: str = "analyst",
78
  description: str = "", created_by: str = "system") -> Dict[str, Any]:
79
  if role not in ROLES:
@@ -92,7 +130,7 @@ def create_key(name: str, role: str = "analyst",
92
  "key_hash": key_hash, "salt": salt, "created_at": _now(), "created_by": created_by,
93
  "last_used": None, "use_count": 0, "active": True,
94
  }
95
- _save_key(entry)
96
  logger.info(f"API key created: {key_id} name={name} role={role}")
97
  return {**entry, "key": raw_key,
98
  "warning": "Save this key now. It will not be shown again.",
@@ -103,7 +141,7 @@ def verify_key(raw_key: str) -> Optional[Dict[str, Any]]:
103
  if not raw_key or not raw_key.startswith("vfx_"):
104
  return None
105
  keys = _load_keys()
106
- for entry in keys.values():
107
  # salt defaults to "" for legacy records created before F-6, which
108
  # reproduces the exact old unsalted hash -- so already-issued keys
109
  # keep verifying correctly with no migration step required.
@@ -112,24 +150,34 @@ def verify_key(raw_key: str) -> Optional[Dict[str, Any]]:
112
  secrets.compare_digest(entry.get("key_hash", ""), expected_hash)
113
  and entry.get("active", False)
114
  ):
115
- entry["last_used"] = _now()
116
- entry["use_count"] = entry.get("use_count", 0) + 1
 
 
 
 
117
  with _key_write_lock:
118
- _save_key(entry)
119
- return {k: v for k, v in entry.items() if k not in ("key_hash", "salt")}
 
 
 
 
 
120
  return None
121
 
122
 
123
 
124
  def revoke_key(key_id: str, revoked_by: str = "system") -> Dict[str, Any]:
125
- keys = _load_keys()
126
- if key_id not in keys:
127
- return {"error": f"Key not found: {key_id}"}
128
- entry = keys[key_id]
129
- entry["active"] = False
130
- entry["revoked_at"] = _now()
131
- entry["revoked_by"] = revoked_by
132
- _save_key(entry)
 
133
  logger.info(f"API key revoked: {key_id}")
134
  return {k: v for k, v in entry.items() if k not in ("key_hash", "salt")}
135
 
 
9
  admin - all methods including key management
10
  """
11
  import json
12
+ import os
13
  import uuid
14
  import hashlib
15
  import secrets
 
75
  logger.error(f"Failed to save API key: {e}")
76
 
77
 
78
+ def _rewrite_keys_file(keys: Dict[str, Dict[str, Any]]) -> None:
79
+ """Atomically rewrite the whole keys file with exactly one line per
80
+ key (F-10).
81
+
82
+ Replaces the previous append-only-forever behavior: _save_key() used
83
+ to append a full copy of an entry on every successful authentication
84
+ (not just on creation), so data/api_keys.jsonl grew by one line per
85
+ auth with no bound, and _load_keys() re-read and re-parsed every
86
+ accumulated line on every single call. Writes to a temp file first
87
+ and os.replace()'s it into place, which is atomic on both POSIX and
88
+ Windows -- a crash mid-write can never leave a corrupt/partial file
89
+ in KEYS_PATH.
90
+ """
91
+ KEYS_PATH.parent.mkdir(parents=True, exist_ok=True)
92
+ tmp_path = KEYS_PATH.with_suffix(KEYS_PATH.suffix + ".tmp")
93
+ with open(tmp_path, "w", encoding="utf-8") as f:
94
+ for entry in keys.values():
95
+ f.write(json.dumps(entry) + "\n")
96
+ os.replace(tmp_path, KEYS_PATH)
97
+
98
+
99
+ def _upsert_key(entry: Dict[str, Any]) -> None:
100
+ """Insert or update a single key entry, compacted (F-10).
101
+
102
+ Performs the full load-merge-rewrite sequence under one lock
103
+ acquisition, so concurrent callers can no longer race on a stale
104
+ read -- this is also what closes the use_count undercount the audit
105
+ flagged alongside the plain unbounded-growth issue (previously only
106
+ the final _save_key() append was lock-protected, not the read and
107
+ increment before it).
108
+ """
109
+ with _key_write_lock:
110
+ keys = _load_keys()
111
+ keys[entry["key_id"]] = entry
112
+ _rewrite_keys_file(keys)
113
+
114
+
115
  def create_key(name: str, role: str = "analyst",
116
  description: str = "", created_by: str = "system") -> Dict[str, Any]:
117
  if role not in ROLES:
 
130
  "key_hash": key_hash, "salt": salt, "created_at": _now(), "created_by": created_by,
131
  "last_used": None, "use_count": 0, "active": True,
132
  }
133
+ _upsert_key(entry)
134
  logger.info(f"API key created: {key_id} name={name} role={role}")
135
  return {**entry, "key": raw_key,
136
  "warning": "Save this key now. It will not be shown again.",
 
141
  if not raw_key or not raw_key.startswith("vfx_"):
142
  return None
143
  keys = _load_keys()
144
+ for key_id, entry in keys.items():
145
  # salt defaults to "" for legacy records created before F-6, which
146
  # reproduces the exact old unsalted hash -- so already-issued keys
147
  # keep verifying correctly with no migration step required.
 
150
  secrets.compare_digest(entry.get("key_hash", ""), expected_hash)
151
  and entry.get("active", False)
152
  ):
153
+ # F-10: re-read the latest on-disk state and increment
154
+ # use_count inside the SAME lock as the write. Previously
155
+ # only the final _save_key() append was lock-protected, not
156
+ # the read-and-increment before it -- two concurrent
157
+ # requests using the same key could both read the same
158
+ # stale use_count, and one increment would be lost.
159
  with _key_write_lock:
160
+ latest_keys = _load_keys()
161
+ latest_entry = latest_keys.get(key_id, entry)
162
+ latest_entry["last_used"] = _now()
163
+ latest_entry["use_count"] = latest_entry.get("use_count", 0) + 1
164
+ latest_keys[key_id] = latest_entry
165
+ _rewrite_keys_file(latest_keys)
166
+ return {k: v for k, v in latest_entry.items() if k not in ("key_hash", "salt")}
167
  return None
168
 
169
 
170
 
171
  def revoke_key(key_id: str, revoked_by: str = "system") -> Dict[str, Any]:
172
+ with _key_write_lock:
173
+ keys = _load_keys()
174
+ if key_id not in keys:
175
+ return {"error": f"Key not found: {key_id}"}
176
+ entry = keys[key_id]
177
+ entry["active"] = False
178
+ entry["revoked_at"] = _now()
179
+ entry["revoked_by"] = revoked_by
180
+ _rewrite_keys_file(keys)
181
  logger.info(f"API key revoked: {key_id}")
182
  return {k: v for k, v in entry.items() if k not in ("key_hash", "salt")}
183
 
backend/services/webhook_manager.py CHANGED
@@ -340,6 +340,25 @@ def _attempt_delivery(
340
  delivery_id: str,
341
  ) -> tuple[bool, Optional[int], Optional[str]]:
342
  """Perform a single HTTP POST. Returns (success, status_code, error)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  req = _URLRequest(
344
  url,
345
  data=body,
 
340
  delivery_id: str,
341
  ) -> tuple[bool, Optional[int], Optional[str]]:
342
  """Perform a single HTTP POST. Returns (success, status_code, error)."""
343
+ # F-9: re-run the same SSRF guard used at registration, immediately
344
+ # before every delivery attempt (including each retry). The
345
+ # registration-time check alone leaves a DNS-rebinding window open --
346
+ # a hostname can resolve to a safe address at registration and to an
347
+ # internal/metadata address later, especially across the retry delays
348
+ # in _deliver_with_retry (up to _RETRY_DELAYS apart). Treated as a
349
+ # delivery failure (not a crash) so it flows through the existing
350
+ # retry/suspend/logging machinery, but logged distinctly so a real
351
+ # rebinding attempt is visible and not indistinguishable from an
352
+ # ordinary network failure.
353
+ try:
354
+ _reject_unsafe_webhook_target(url)
355
+ except ValueError as exc:
356
+ logger.error(
357
+ "Webhook %s delivery blocked at send-time SSRF check: %s",
358
+ webhook_id, exc,
359
+ )
360
+ return False, None, f"Blocked by delivery-time SSRF guard: {exc}"
361
+
362
  req = _URLRequest(
363
  url,
364
  data=body,
backend/tests/test_api_keys.py CHANGED
@@ -149,3 +149,81 @@ def test_api_verify_endpoint_invalid(client):
149
  response = client.get("/api/v1/keys/verify",
150
  headers={"Authorization": "Bearer vfx_invalid"})
151
  assert response.status_code == 401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  response = client.get("/api/v1/keys/verify",
150
  headers={"Authorization": "Bearer vfx_invalid"})
151
  assert response.status_code == 401
152
+
153
+
154
+ # ── F-10: compacted storage (no unbounded growth) + use_count race ─────────
155
+
156
+ def test_repeated_auth_does_not_grow_the_file(temp_keys_file):
157
+ """F-10 regression test: the file must stay at exactly one line per
158
+ key regardless of how many times that key is used to authenticate --
159
+ previously _save_key() appended a full copy on every successful
160
+ verify_key() call, so a single key used 100 times produced 100+
161
+ lines instead of 1."""
162
+ from backend.services.api_key_manager import create_key, verify_key
163
+
164
+ created = create_key("Repeated Use Key", role="analyst")
165
+ for _ in range(25):
166
+ result = verify_key(created["key"])
167
+ assert result is not None
168
+
169
+ line_count = sum(1 for line in temp_keys_file.read_text().splitlines() if line.strip())
170
+ assert line_count == 1, (
171
+ f"expected exactly 1 line for 1 key after 25 authentications, got {line_count} -- "
172
+ f"the file is growing unbounded again (F-10)"
173
+ )
174
+
175
+
176
+ def test_multiple_keys_stay_one_line_each(temp_keys_file):
177
+ from backend.services.api_key_manager import create_key, verify_key
178
+
179
+ keys = [create_key(f"Key {i}", role="analyst") for i in range(5)]
180
+ for k in keys:
181
+ verify_key(k["key"])
182
+ verify_key(k["key"])
183
+
184
+ line_count = sum(1 for line in temp_keys_file.read_text().splitlines() if line.strip())
185
+ assert line_count == 5, f"expected exactly 5 lines for 5 keys, got {line_count}"
186
+
187
+
188
+ def test_use_count_accurate_under_concurrent_verification():
189
+ """F-10 regression test: concurrent requests using the SAME key must
190
+ not lose increments. Previously only the final _save_key() append
191
+ was lock-protected, not the read-and-increment before it, so two
192
+ threads could read the same stale use_count and one increment would
193
+ be silently lost.
194
+ """
195
+ import threading
196
+ from backend.services.api_key_manager import create_key, verify_key, list_keys
197
+
198
+ created = create_key("Concurrency Test Key", role="analyst")
199
+ raw_key = created["key"]
200
+
201
+ N_THREADS = 20
202
+ barrier = threading.Barrier(N_THREADS)
203
+
204
+ def _do_verify():
205
+ barrier.wait() # maximize actual overlap
206
+ verify_key(raw_key)
207
+
208
+ threads = [threading.Thread(target=_do_verify) for _ in range(N_THREADS)]
209
+ for t in threads:
210
+ t.start()
211
+ for t in threads:
212
+ t.join(timeout=5)
213
+
214
+ entries = list_keys(include_inactive=True)
215
+ entry = next(e for e in entries if e["key_id"] == created["key_id"])
216
+ assert entry["use_count"] == N_THREADS, (
217
+ f"expected use_count == {N_THREADS} after {N_THREADS} concurrent verifications, "
218
+ f"got {entry['use_count']} -- increments were lost to the race (F-10)"
219
+ )
220
+
221
+
222
+ def test_revoke_does_not_duplicate_lines(temp_keys_file):
223
+ from backend.services.api_key_manager import create_key, revoke_key
224
+
225
+ created = create_key("Revoke Compaction Test", role="analyst")
226
+ revoke_key(created["key_id"])
227
+
228
+ line_count = sum(1 for line in temp_keys_file.read_text().splitlines() if line.strip())
229
+ assert line_count == 1, f"expected exactly 1 line after create+revoke, got {line_count}"
backend/tests/test_webhooks.py CHANGED
@@ -202,3 +202,62 @@ class TestAPIAuth:
202
  """GET /api/v1/webhooks/deliveries without auth → 401."""
203
  resp = client.get("/api/v1/webhooks/deliveries")
204
  assert resp.status_code == 401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  """GET /api/v1/webhooks/deliveries without auth → 401."""
203
  resp = client.get("/api/v1/webhooks/deliveries")
204
  assert resp.status_code == 401
205
+
206
+
207
+ class TestDeliveryTimeSSRFGuard:
208
+ """F-9 regression tests: the SSRF guard must run again immediately
209
+ before each delivery attempt, not just once at registration -- a
210
+ hostname can resolve to a safe address at registration time and to
211
+ an internal/metadata address later (DNS rebinding), especially
212
+ across the retry delays in _deliver_with_retry.
213
+ """
214
+
215
+ def test_attempt_delivery_blocks_when_target_becomes_unsafe(self, monkeypatch):
216
+ """Simulates DNS rebinding: the target was safe at registration,
217
+ but resolves to a disallowed address by the time delivery is
218
+ attempted. _attempt_delivery must reject it and must NOT reach
219
+ the network at all.
220
+ """
221
+ import backend.services.webhook_manager as wm
222
+
223
+ def _now_unsafe(url):
224
+ raise ValueError(f"Webhook URL resolves to a disallowed address (simulated rebinding to 169.254.169.254).")
225
+
226
+ monkeypatch.setattr(wm, "_reject_unsafe_webhook_target", _now_unsafe)
227
+
228
+ network_was_called = []
229
+ def _fail_if_called(*args, **kwargs):
230
+ network_was_called.append(True)
231
+ raise AssertionError("must not reach the network once the SSRF guard rejects the target")
232
+ monkeypatch.setattr(wm, "_URLRequest", _fail_if_called)
233
+
234
+ success, status_code, error = wm._attempt_delivery(
235
+ "https://example.com/hook", b"{}", "sig", "webhook-1", "delivery-1"
236
+ )
237
+
238
+ assert success is False
239
+ assert status_code is None
240
+ assert "SSRF" in error
241
+ assert network_was_called == [], "delivery attempted network access despite the SSRF guard rejecting it"
242
+
243
+ def test_attempt_delivery_proceeds_when_target_still_safe(self, monkeypatch):
244
+ """The common case: target is still safe at delivery time --
245
+ must not be blocked, and should reach the (mocked) network."""
246
+ import backend.services.webhook_manager as wm
247
+
248
+ monkeypatch.setattr(wm, "_reject_unsafe_webhook_target", lambda url: None)
249
+
250
+ class _FakeResponse:
251
+ status = 200
252
+ def __enter__(self): return self
253
+ def __exit__(self, *a): return False
254
+
255
+ monkeypatch.setattr(wm, "urlopen", lambda req, timeout=10: _FakeResponse())
256
+
257
+ success, status_code, error = wm._attempt_delivery(
258
+ "https://example.com/hook", b"{}", "sig", "webhook-1", "delivery-1"
259
+ )
260
+
261
+ assert success is True
262
+ assert status_code == 200
263
+ assert error is None