Spaces:
Running
Running
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 |
-
|
| 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.
|
| 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 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
with _key_write_lock:
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
return None
|
| 121 |
|
| 122 |
|
| 123 |
|
| 124 |
def revoke_key(key_id: str, revoked_by: str = "system") -> Dict[str, Any]:
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 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
|