test_auth_fail_closed.py 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. """Regression tests for the GHSA-6mf4-q26m-47pv fail-open auth bypass.
  2. The previous version of ``is_auth_enabled`` caught every exception and
  3. returned False (auth disabled). An attacker could trigger a DB-side
  4. exception — the documented PoC exhausts file descriptors via a flood on
  5. ``/api/v1/auth/login`` until the next SQLite ``connect`` raises — and then
  6. hit any protected endpoint during that fail-open window with no token at
  7. all. Severity CVSS 9.8.
  8. These tests pin the fail-closed contract:
  9. 1. ``is_auth_enabled`` propagates any DB exception (instead of swallowing
  10. it and returning False).
  11. 2. The "no settings row" path still returns False (auth was legitimately
  12. never configured).
  13. 3. ``setting.value == "true"`` still returns True.
  14. """
  15. from unittest.mock import AsyncMock, MagicMock
  16. import pytest
  17. from backend.app.core.auth import is_auth_enabled
  18. @pytest.mark.asyncio
  19. async def test_is_auth_enabled_propagates_db_exception_instead_of_failing_open():
  20. """The core regression for GHSA-6mf4-q26m-47pv. A DB error during the
  21. auth-enabled probe must propagate — fail closed — instead of returning
  22. False and treating the system as auth-disabled."""
  23. db = AsyncMock()
  24. db.execute = AsyncMock(side_effect=OSError("simulated file-descriptor exhaustion"))
  25. with pytest.raises(OSError, match="simulated file-descriptor exhaustion"):
  26. await is_auth_enabled(db)
  27. @pytest.mark.asyncio
  28. async def test_is_auth_enabled_returns_false_when_settings_row_absent():
  29. """Legitimate 'auth was never configured' path: the settings row simply
  30. does not exist. ``scalar_one_or_none`` returns None, no exception, and
  31. the function returns False — system is auth-disabled by configuration,
  32. not because the DB blew up."""
  33. result = MagicMock()
  34. result.scalar_one_or_none = MagicMock(return_value=None)
  35. db = AsyncMock()
  36. db.execute = AsyncMock(return_value=result)
  37. assert await is_auth_enabled(db) is False
  38. @pytest.mark.asyncio
  39. async def test_is_auth_enabled_returns_true_when_setting_value_is_true():
  40. """Happy path: the settings row exists and its value is "true" → auth
  41. is enabled and the caller must require credentials."""
  42. setting = MagicMock()
  43. setting.value = "true"
  44. result = MagicMock()
  45. result.scalar_one_or_none = MagicMock(return_value=setting)
  46. db = AsyncMock()
  47. db.execute = AsyncMock(return_value=result)
  48. assert await is_auth_enabled(db) is True
  49. @pytest.mark.asyncio
  50. async def test_is_auth_enabled_returns_false_when_setting_value_is_false():
  51. """Happy path: the settings row exists and its value is "false" → auth
  52. is disabled by configuration (legitimate, not exception)."""
  53. setting = MagicMock()
  54. setting.value = "false"
  55. result = MagicMock()
  56. result.scalar_one_or_none = MagicMock(return_value=setting)
  57. db = AsyncMock()
  58. db.execute = AsyncMock(return_value=result)
  59. assert await is_auth_enabled(db) is False