test_refused_header_error_texts.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. """A stored credential must not come back in an error's text.
  2. An HTTP client refusing a header (a stored token with a stray space or line
  3. break) quotes the header's value. The Git backup's results and logs and the
  4. Home Assistant connection test returned that text, so a saved credential, which
  5. the API otherwise never hands back, could be read in an error message.
  6. """
  7. from __future__ import annotations
  8. import socket
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  13. from backend.app.services.homeassistant import HomeAssistantService
  14. # Made up. Never paste a value from a real log here.
  15. SECRET = "git-refused-value-3"
  16. REFUSED = f"Illegal header value b'token {SECRET} '"
  17. _CONNECTED = {
  18. "success": True,
  19. "message": "Connection successful",
  20. "repo_name": "test/repo",
  21. "permissions": {"push": True},
  22. "is_private": True,
  23. }
  24. async def _configure_backup(async_client: AsyncClient) -> None:
  25. with patch(
  26. "backend.app.services.github_backup.github_backup_service.test_connection",
  27. new=AsyncMock(return_value=_CONNECTED),
  28. ):
  29. response = await async_client.post(
  30. "/api/v1/github-backup/config",
  31. json={
  32. "repository_url": "https://github.com/test/repo",
  33. "access_token": "ghp_token",
  34. "branch": "main",
  35. "schedule_enabled": False,
  36. "schedule_type": "daily",
  37. "backup_kprofiles": True,
  38. "backup_cloud_profiles": False,
  39. "backup_settings": False,
  40. "enabled": True,
  41. },
  42. )
  43. assert response.status_code == 200, response.text
  44. @pytest.mark.asyncio
  45. @pytest.mark.integration
  46. @pytest.mark.parametrize(
  47. "push",
  48. [
  49. AsyncMock(side_effect=RuntimeError(REFUSED)),
  50. AsyncMock(return_value={"status": "failed", "message": REFUSED, "error": REFUSED}),
  51. ],
  52. ids=["raised", "returned"],
  53. )
  54. async def test_a_failed_backup_reports_no_header_value(async_client: AsyncClient, db_session, push):
  55. await _configure_backup(async_client)
  56. # The backup opens its own sessions.
  57. sessions = async_sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False)
  58. with (
  59. patch("backend.app.services.github_backup.github_backup_service._push_to_provider", new=push),
  60. patch("backend.app.services.github_backup.async_session", sessions),
  61. patch(
  62. "backend.app.services.github_backup.github_backup_service.test_connection",
  63. new=AsyncMock(return_value=_CONNECTED),
  64. ),
  65. ):
  66. run = await async_client.post("/api/v1/github-backup/run")
  67. config = await async_client.get("/api/v1/github-backup/config")
  68. logs = await async_client.get("/api/v1/github-backup/logs")
  69. for response in (run, config, logs):
  70. assert response.status_code == 200, response.text
  71. assert SECRET not in response.text
  72. assert "Illegal header value [REDACTED]" in run.text
  73. assert "Illegal header value [REDACTED]" in logs.text
  74. @pytest.mark.asyncio
  75. @pytest.mark.integration
  76. async def test_the_stored_token_test_reports_no_header_value(async_client: AsyncClient):
  77. await _configure_backup(async_client)
  78. backend = MagicMock()
  79. backend.test_connection = AsyncMock(return_value={"success": False, "message": REFUSED})
  80. with patch("backend.app.services.github_backup.get_provider_backend", return_value=backend):
  81. response = await async_client.post("/api/v1/github-backup/test-stored")
  82. assert response.status_code == 200, response.text
  83. assert SECRET not in response.text
  84. assert "Illegal header value [REDACTED]" in response.text
  85. @pytest.mark.asyncio
  86. async def test_the_home_assistant_test_reports_no_header_value():
  87. with socket.socket() as server:
  88. server.bind(("127.0.0.1", 0))
  89. server.listen()
  90. url = f"http://127.0.0.1:{server.getsockname()[1]}"
  91. with patch.object(HomeAssistantService, "_validate_url", side_effect=lambda u: u):
  92. result = await HomeAssistantService(timeout=2).test_connection(url, f"{SECRET} ")
  93. assert result["success"] is False
  94. assert SECRET not in result["error"]
  95. assert "Illegal header value [REDACTED]" in result["error"]
  96. @pytest.mark.asyncio
  97. @pytest.mark.integration
  98. async def test_errors_stored_before_masking_are_served_masked(async_client: AsyncClient, db_session):
  99. """Rows written by an earlier version still hold the quoted value."""
  100. from backend.app.models.github_backup import GitHubBackupLog
  101. from backend.app.models.notification import NotificationLog, NotificationProvider
  102. provider = NotificationProvider(
  103. name="HA", provider_type="homeassistant", enabled=True, config="{}", last_error=REFUSED
  104. )
  105. db_session.add(provider)
  106. await db_session.flush()
  107. db_session.add(
  108. NotificationLog(
  109. provider_id=provider.id, event_type="test", title="t", message="m", success=False, error_message=REFUSED
  110. )
  111. )
  112. await db_session.commit()
  113. await _configure_backup(async_client)
  114. config = (await async_client.get("/api/v1/github-backup/config")).json()
  115. db_session.add(GitHubBackupLog(config_id=config["id"], status="failed", trigger="manual", error_message=REFUSED))
  116. await db_session.commit()
  117. for path in ("/api/v1/notifications/", "/api/v1/notifications/logs", "/api/v1/github-backup/logs"):
  118. response = await async_client.get(path)
  119. assert response.status_code == 200, (path, response.text)
  120. assert SECRET not in response.text, path
  121. assert "Illegal header value [REDACTED]" in response.text, path
  122. @pytest.mark.asyncio
  123. @pytest.mark.integration
  124. async def test_the_restore_commit_list_reports_no_header_value(async_client: AsyncClient):
  125. await _configure_backup(async_client)
  126. backend = MagicMock()
  127. backend.list_commits = AsyncMock(
  128. return_value={"success": False, "message": f"LocalProtocolError: {REFUSED}", "commits": []}
  129. )
  130. with patch("backend.app.services.github_restore.get_provider_backend", return_value=backend):
  131. response = await async_client.get("/api/v1/github-backup/commits")
  132. assert SECRET not in response.text
  133. assert "Illegal header value [REDACTED]" in response.text
  134. @pytest.mark.asyncio
  135. @pytest.mark.integration
  136. async def test_the_obico_status_reports_no_header_value(async_client: AsyncClient):
  137. from backend.app.services.obico_detection import obico_detection_service
  138. with patch.object(obico_detection_service, "_last_error", f"ML API call failed for printer 1: {REFUSED}"):
  139. response = await async_client.get("/api/v1/obico/status")
  140. assert response.status_code == 200, response.text
  141. assert SECRET not in response.text
  142. assert "Illegal header value [REDACTED]" in response.text