test_query_token_redaction.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. """Query-string tokens must not reach the logs.
  2. The SPA authenticates its WebSocket and media requests with a short-lived
  3. token in the query string, and uvicorn logs every path with its query -- the
  4. access line for a GET and the "WebSocket ... [accepted]" line for an upgrade.
  5. Those go to the console, and from there into `docker logs` and support
  6. bundles built from them. ``QueryTokenRedactFilter`` masks the value on the way
  7. out; ``sanitize_log_content`` does the same for log text already written.
  8. """
  9. from __future__ import annotations
  10. import io
  11. import logging
  12. import pytest
  13. from backend.app.core.logging_filters import QueryTokenRedactFilter, redact_query_tokens
  14. from backend.app.services.log_reader import sanitize_log_content
  15. TOKEN = "M8qxtTy4sHgSkUhAwwrOHSji8stuLIq0"
  16. def _record(msg: str, args) -> logging.LogRecord:
  17. return logging.LogRecord(
  18. name="uvicorn.error", level=logging.INFO, pathname="", lineno=0, msg=msg, args=args, exc_info=None
  19. )
  20. class TestRedactQueryTokens:
  21. @pytest.mark.parametrize(
  22. "text, expected",
  23. [
  24. (f"/api/v1/ws?token={TOKEN}", "/api/v1/ws?token=[REDACTED]"),
  25. (
  26. f"/api/v1/printers/1/camera/stream?fps=10&token={TOKEN}",
  27. "/api/v1/printers/1/camera/stream?fps=10&token=[REDACTED]",
  28. ),
  29. (f"/x?token={TOKEN}&fps=5", "/x?token=[REDACTED]&fps=5"),
  30. (f'"WebSocket /api/v1/ws?token={TOKEN}" [accepted]', '"WebSocket /api/v1/ws?token=[REDACTED]" [accepted]'),
  31. (f"/x?access_token={TOKEN}", "/x?access_token=[REDACTED]"),
  32. (f"/x?api_key={TOKEN}", "/x?api_key=[REDACTED]"),
  33. ],
  34. )
  35. def test_masks_the_value(self, text, expected):
  36. assert redact_query_tokens(text) == expected
  37. @pytest.mark.parametrize(
  38. "text",
  39. [
  40. "/api/v1/printers?status=online",
  41. "/api/v1/archives?mytoken=abc", # a different parameter that merely ends in "token"
  42. "token=abc in prose, not a query",
  43. "",
  44. None,
  45. ],
  46. )
  47. def test_leaves_everything_else_alone(self, text):
  48. assert redact_query_tokens(text) == text
  49. class TestQueryTokenRedactFilter:
  50. def test_websocket_accept_line(self):
  51. """Uvicorn's own format: the path is an argument, not part of msg."""
  52. record = _record('%s - "WebSocket %s" [accepted]', ("192.168.255.4:0", f"/api/v1/ws?token={TOKEN}"))
  53. assert QueryTokenRedactFilter().filter(record) is True
  54. assert TOKEN not in record.getMessage()
  55. assert record.getMessage() == '192.168.255.4:0 - "WebSocket /api/v1/ws?token=[REDACTED]" [accepted]'
  56. def test_access_line_keeps_its_other_arguments(self):
  57. record = _record(
  58. '%s - "%s %s HTTP/%s" %d',
  59. ("10.0.0.2:5000", "GET", f"/api/v1/printers/1/camera/stream?token={TOKEN}", "1.1", 200),
  60. )
  61. QueryTokenRedactFilter().filter(record)
  62. assert record.getMessage() == (
  63. '10.0.0.2:5000 - "GET /api/v1/printers/1/camera/stream?token=[REDACTED] HTTP/1.1" 200'
  64. )
  65. def test_message_without_arguments(self):
  66. record = _record(f"GET /api/v1/ws?token={TOKEN}", None)
  67. QueryTokenRedactFilter().filter(record)
  68. assert record.getMessage() == "GET /api/v1/ws?token=[REDACTED]"
  69. def test_through_a_real_logger_and_handler(self):
  70. """Attached to the logger, it covers whatever handler writes the line."""
  71. logger = logging.getLogger("test.query_token_redaction")
  72. stream = io.StringIO()
  73. handler = logging.StreamHandler(stream)
  74. logger.addHandler(handler)
  75. logger.addFilter(QueryTokenRedactFilter())
  76. logger.setLevel(logging.INFO)
  77. logger.propagate = False
  78. try:
  79. logger.info('%s - "WebSocket %s" [accepted]', "fe80::1:0", f"/api/v1/ws?token={TOKEN}")
  80. finally:
  81. logger.removeHandler(handler)
  82. assert TOKEN not in stream.getvalue()
  83. assert "token=[REDACTED]" in stream.getvalue()
  84. def test_main_attaches_the_filter_to_both_uvicorn_loggers():
  85. import backend.app.main # noqa: F401 -- importing configures logging
  86. for name in ("uvicorn.access", "uvicorn.error"):
  87. assert any(isinstance(f, QueryTokenRedactFilter) for f in logging.getLogger(name).filters), name
  88. def test_support_bundle_sanitizer_masks_tokens():
  89. line = f'INFO: 192.168.255.4:0 - "WebSocket /api/v1/ws?token={TOKEN}" [accepted]'
  90. assert TOKEN not in sanitize_log_content(line)
  91. assert "token=[REDACTED]" in sanitize_log_content(line)