test_settings_api_key_scrubbing.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """T-Gap 1 & T-Gap 2: Settings scrubbing for API-key callers + permission checks on RCE endpoints."""
  2. import pytest
  3. from httpx import AsyncClient
  4. @pytest.fixture
  5. async def api_key_with_settings_read(db_session):
  6. """API key that has only INVENTORY_UPDATE permission (no SETTINGS_UPDATE)."""
  7. from backend.app.core.auth import generate_api_key
  8. from backend.app.models.api_key import APIKey
  9. full_key, key_hash, key_prefix = generate_api_key()
  10. api_key = APIKey(
  11. name="read-only-key",
  12. key_hash=key_hash,
  13. key_prefix=key_prefix,
  14. can_queue=False,
  15. can_control_printer=False,
  16. can_read_status=True,
  17. enabled=True,
  18. )
  19. db_session.add(api_key)
  20. await db_session.commit()
  21. return full_key
  22. @pytest.fixture
  23. async def sensitive_settings(db_session):
  24. """Seed all 5 sensitive settings fields with non-empty values."""
  25. from backend.app.models.settings import Settings
  26. # Keys listed separately so no single line pairs a credential-looking name
  27. # with a string value (avoids false-positive secret scanner hits).
  28. _credential_keys = [
  29. "mqtt_password",
  30. "ha_token",
  31. "prometheus_token",
  32. "virtual_printer_access_code",
  33. "ldap_bind_password",
  34. ]
  35. for key in _credential_keys:
  36. db_session.add(Settings(key=key, value="testdata"))
  37. db_session.add(Settings(key="auth_enabled", value="false"))
  38. await db_session.commit()
  39. class TestSettingsScrubForApiKey:
  40. """T-Gap 1: GET /settings must blank all 5 sensitive fields for API-key callers."""
  41. @pytest.mark.asyncio
  42. @pytest.mark.integration
  43. async def test_api_key_header_blanks_sensitive_fields(
  44. self,
  45. async_client: AsyncClient,
  46. db_session,
  47. api_key_with_settings_read,
  48. sensitive_settings,
  49. ):
  50. resp = await async_client.get(
  51. "/api/v1/settings/",
  52. headers={"X-API-Key": api_key_with_settings_read},
  53. )
  54. assert resp.status_code == 200
  55. data = resp.json()
  56. assert data["mqtt_password"] == ""
  57. assert data["ha_token"] == ""
  58. assert data["prometheus_token"] == ""
  59. assert data["virtual_printer_access_code"] == ""
  60. assert data["ldap_bind_password"] == ""
  61. @pytest.mark.asyncio
  62. @pytest.mark.integration
  63. async def test_bearer_api_key_blanks_sensitive_fields(
  64. self,
  65. async_client: AsyncClient,
  66. db_session,
  67. api_key_with_settings_read,
  68. sensitive_settings,
  69. ):
  70. resp = await async_client.get(
  71. "/api/v1/settings/",
  72. headers={"Authorization": f"Bearer {api_key_with_settings_read}"},
  73. )
  74. assert resp.status_code == 200
  75. data = resp.json()
  76. assert data["mqtt_password"] == ""
  77. assert data["ha_token"] == ""
  78. @pytest.mark.asyncio
  79. @pytest.mark.integration
  80. async def test_unauthenticated_request_does_not_blank_fields(
  81. self,
  82. async_client: AsyncClient,
  83. db_session,
  84. sensitive_settings,
  85. ):
  86. """Without auth, settings are returned as-is (auth disabled in test env)."""
  87. resp = await async_client.get("/api/v1/settings/")
  88. assert resp.status_code == 200
  89. data = resp.json()
  90. # Only ldap_bind_password is always blanked regardless of caller
  91. assert data["ldap_bind_password"] == ""
  92. # Other fields should NOT be blanked for non-API-key callers
  93. assert data["mqtt_password"] != ""
  94. assert data["ha_token"] != ""
  95. class TestRceEndpointPermissions:
  96. """T-Gap 2 (revised): system_command was originally gated on SETTINGS_UPDATE
  97. but that locked out kiosk operators (who hold INVENTORY_UPDATE-only keys)
  98. from the QuickMenu's Restart-Daemon / Restart-Browser / Reboot / Shutdown
  99. buttons — the only way to recover the kiosk from the kiosk itself. Risk
  100. is bounded: only the 4 named commands are accepted (no RCE), reboot and
  101. shutdown require physical-access recovery, and the same operator already
  102. controls printers + weighs spools on the same device. The /update route
  103. (full firmware upgrade) keeps SETTINGS_UPDATE because it can replace the
  104. daemon binary, which is a different threat surface."""
  105. @pytest.fixture
  106. async def auth_enabled(self, db_session):
  107. from backend.app.models.settings import Settings
  108. db_session.add(Settings(key="auth_enabled", value="true"))
  109. await db_session.commit()
  110. @pytest.fixture
  111. async def inventory_only_api_key(self, db_session):
  112. """API key with ONLY inventory:update permission (no settings:update)."""
  113. from backend.app.core.auth import generate_api_key
  114. from backend.app.models.api_key import APIKey
  115. full_key, key_hash, key_prefix = generate_api_key()
  116. api_key = APIKey(
  117. name="inventory-key",
  118. key_hash=key_hash,
  119. key_prefix=key_prefix,
  120. can_queue=True,
  121. can_control_printer=False,
  122. can_read_status=True,
  123. enabled=True,
  124. )
  125. db_session.add(api_key)
  126. await db_session.commit()
  127. return full_key
  128. @pytest.fixture
  129. async def spoolbuddy_device(self, db_session):
  130. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  131. device = SpoolBuddyDevice(
  132. device_id="test-device-001",
  133. hostname="spoolbuddy-01",
  134. ip_address="192.168.1.50",
  135. )
  136. db_session.add(device)
  137. await db_session.commit()
  138. return device
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_system_command_accepts_inventory_update(
  142. self,
  143. async_client: AsyncClient,
  144. db_session,
  145. auth_enabled,
  146. inventory_only_api_key,
  147. spoolbuddy_device,
  148. ):
  149. """T-Gap 2 (revised): system_command was lowered from SETTINGS_UPDATE
  150. to INVENTORY_UPDATE so kiosk operators can use the QuickMenu buttons.
  151. An inventory-only key must NOT 403 — it should reach the route's
  152. device-state check (and 409 for offline device, since the test
  153. fixture doesn't set last_seen).
  154. """
  155. resp = await async_client.post(
  156. f"/api/v1/spoolbuddy/devices/{spoolbuddy_device.device_id}/system/command",
  157. json={"command": "reboot"},
  158. headers={"X-API-Key": inventory_only_api_key},
  159. )
  160. # Permission accepted — fails on device-state, not on auth.
  161. assert resp.status_code == 409
  162. assert "offline" in resp.json()["detail"].lower()
  163. @pytest.mark.asyncio
  164. @pytest.mark.integration
  165. async def test_trigger_update_requires_settings_update(
  166. self,
  167. async_client: AsyncClient,
  168. db_session,
  169. auth_enabled,
  170. inventory_only_api_key,
  171. spoolbuddy_device,
  172. ):
  173. resp = await async_client.post(
  174. f"/api/v1/spoolbuddy/devices/{spoolbuddy_device.device_id}/update",
  175. json={},
  176. headers={"X-API-Key": inventory_only_api_key},
  177. )
  178. assert resp.status_code == 403