test_refused_header_redaction.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. """A refused header's value must not reach the live logs.
  2. An HTTP client that refuses a header quotes its value in the error, and that
  3. value is commonly a token (Home Assistant's ``Authorization: Bearer ...`` with
  4. a stray space or newline). Services log such errors with ``%s`` or with their
  5. traceback, so the text went to the console, ``docker logs`` and bambuddy.log;
  6. only the support download masked it. ``RefusedHeaderRedactFilter`` masks it
  7. before any handler writes the line.
  8. """
  9. from __future__ import annotations
  10. import io
  11. import logging
  12. import socket
  13. import httpx
  14. import pytest
  15. from backend.app.core.logging_filters import RefusedHeaderRedactFilter
  16. # Made up. Never paste a value from a real log here.
  17. SECRET = "hdr-live-value-1"
  18. @pytest.fixture
  19. def logged():
  20. """A logger writing through a handler that carries the filter, as main.py's do."""
  21. logger = logging.getLogger("test.refused_header_redaction")
  22. stream = io.StringIO()
  23. handler = logging.StreamHandler(stream)
  24. handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
  25. handler.addFilter(RefusedHeaderRedactFilter())
  26. logger.addHandler(handler)
  27. logger.setLevel(logging.DEBUG)
  28. logger.propagate = False
  29. try:
  30. yield logger, stream
  31. finally:
  32. logger.removeHandler(handler)
  33. def _refused_header_error() -> Exception:
  34. """The error httpx really raises for a header value with a trailing space."""
  35. with socket.socket() as server:
  36. server.bind(("127.0.0.1", 0))
  37. server.listen()
  38. url = f"http://127.0.0.1:{server.getsockname()[1]}/api/states"
  39. try:
  40. with httpx.Client(timeout=2) as client:
  41. client.get(url, headers={"Authorization": f"Bearer {SECRET} "})
  42. except Exception as e: # noqa: BLE001 -- whatever httpx raises is the point
  43. return e
  44. raise AssertionError("httpx accepted the header")
  45. def test_the_real_httpx_error_quotes_the_value():
  46. """Guards the premise: if httpx stops quoting the value, this filter is moot."""
  47. assert SECRET in str(_refused_header_error())
  48. def test_an_error_logged_as_an_argument_is_masked(logged):
  49. logger, stream = logged
  50. logger.warning("Failed to get HA entity state for %s: %s", "switch.x", _refused_header_error())
  51. text = stream.getvalue()
  52. assert SECRET not in text
  53. assert "Failed to get HA entity state for switch.x: Illegal header value [REDACTED]" in text
  54. def test_an_error_logged_with_its_traceback_is_masked(logged):
  55. logger, stream = logged
  56. try:
  57. raise _refused_header_error()
  58. except Exception:
  59. logger.exception("Failed to list HA entities")
  60. text = stream.getvalue()
  61. assert SECRET not in text
  62. assert "Failed to list HA entities" in text
  63. assert "Traceback" in text
  64. assert "Illegal header value [REDACTED]" in text
  65. def test_a_refused_header_name_is_masked(logged):
  66. logger, stream = logged
  67. logger.error("Send failed: %s", f"Illegal header name b'X-{SECRET}'")
  68. assert SECRET not in stream.getvalue()
  69. assert "Send failed: Illegal header name [REDACTED]" in stream.getvalue()
  70. def test_other_records_are_left_as_they_are():
  71. record = logging.LogRecord("x", logging.INFO, "", 0, "%d items in %s", (3, "queue"), None)
  72. assert RefusedHeaderRedactFilter().filter(record) is True
  73. assert record.msg == "%d items in %s" and record.args == (3, "queue")
  74. def test_main_attaches_the_filter_where_lines_are_written():
  75. """Root's handlers (console, bambuddy.log) and uvicorn's loggers, which do
  76. not hand records to root."""
  77. import backend.app.main as main
  78. handlers = [main.console_handler]
  79. if getattr(main, "file_handler", None) is not None:
  80. handlers.append(main.file_handler)
  81. for handler in handlers:
  82. assert any(isinstance(f, RefusedHeaderRedactFilter) for f in handler.filters), handler
  83. for name in ("uvicorn.access", "uvicorn.error"):
  84. assert any(isinstance(f, RefusedHeaderRedactFilter) for f in logging.getLogger(name).filters), name
  85. def test_a_record_whose_arguments_do_not_fit_does_not_raise_from_the_filter():
  86. """A bad format string is reported by the handler as before; the filter
  87. must not turn it into an exception at the caller's log call."""
  88. record = logging.LogRecord("x", logging.INFO, "", 0, "%d items", ("three",), None)
  89. assert RefusedHeaderRedactFilter().filter(record) is True
  90. def test_uvicorns_access_line_keeps_its_argument_tuple():
  91. """uvicorn's console AccessFormatter unpacks record.args into five fields,
  92. and a request path is the client's to choose."""
  93. from uvicorn.logging import AccessFormatter
  94. logger = logging.getLogger("test.refused_header_redaction.access")
  95. stream = io.StringIO()
  96. handler = logging.StreamHandler(stream)
  97. handler.setFormatter(AccessFormatter('%(client_addr)s - "%(request_line)s" %(status_code)s', use_colors=False))
  98. logger.addHandler(handler)
  99. logger.addFilter(RefusedHeaderRedactFilter())
  100. logger.propagate = False
  101. try:
  102. logger.warning('%s - "%s %s HTTP/%s" %d', "1.2.3.4:5", "POST", f"/Illegal header value b'{SECRET}'", "1.1", 404)
  103. finally:
  104. logger.removeHandler(handler)
  105. text = stream.getvalue()
  106. assert SECRET not in text
  107. assert text.startswith('1.2.3.4:5 - "POST /Illegal header value [REDACTED]')
  108. assert "404" in text
  109. def test_a_quote_split_between_format_string_and_argument_is_masked(logged):
  110. logger, stream = logged
  111. logger.warning("Illegal header value %s", f"b'Bearer {SECRET} '")
  112. assert SECRET not in stream.getvalue()
  113. assert "Illegal header value [REDACTED]" in stream.getvalue()
  114. assert "Logging error" not in stream.getvalue()