test_query_token_redaction.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  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. # Made up. Never paste a token from a real log here, expired or not.
  16. TOKEN = "fake-test-token"
  17. def _record(msg: str, args) -> logging.LogRecord:
  18. return logging.LogRecord(
  19. name="uvicorn.error", level=logging.INFO, pathname="", lineno=0, msg=msg, args=args, exc_info=None
  20. )
  21. class TestRedactQueryTokens:
  22. @pytest.mark.parametrize(
  23. "text, expected",
  24. [
  25. (f"/api/v1/ws?token={TOKEN}", "/api/v1/ws?token=[REDACTED]"),
  26. (
  27. f"/api/v1/printers/1/camera/stream?fps=10&token={TOKEN}",
  28. "/api/v1/printers/1/camera/stream?fps=10&token=[REDACTED]",
  29. ),
  30. (f"/x?token={TOKEN}&fps=5", "/x?token=[REDACTED]&fps=5"),
  31. (f'"WebSocket /api/v1/ws?token={TOKEN}" [accepted]', '"WebSocket /api/v1/ws?token=[REDACTED]" [accepted]'),
  32. (f"/x?access_token={TOKEN}", "/x?access_token=[REDACTED]"),
  33. (f"/x?api_key={TOKEN}", "/x?api_key=[REDACTED]"),
  34. ],
  35. )
  36. def test_masks_the_value(self, text, expected):
  37. assert redact_query_tokens(text) == expected
  38. @pytest.mark.parametrize(
  39. "text",
  40. [
  41. "/api/v1/printers?status=online",
  42. "/api/v1/archives?mytoken=abc", # a different parameter that merely ends in "token"
  43. "token=abc in prose, not a query",
  44. "",
  45. None,
  46. ],
  47. )
  48. def test_leaves_everything_else_alone(self, text):
  49. assert redact_query_tokens(text) == text
  50. class TestQueryTokenRedactFilter:
  51. def test_websocket_accept_line(self):
  52. """Uvicorn's own format: the path is an argument, not part of msg."""
  53. record = _record('%s - "WebSocket %s" [accepted]', ("192.168.255.4:0", f"/api/v1/ws?token={TOKEN}"))
  54. assert QueryTokenRedactFilter().filter(record) is True
  55. assert TOKEN not in record.getMessage()
  56. assert record.getMessage() == '192.168.255.4:0 - "WebSocket /api/v1/ws?token=[REDACTED]" [accepted]'
  57. def test_access_line_keeps_its_other_arguments(self):
  58. record = _record(
  59. '%s - "%s %s HTTP/%s" %d',
  60. ("10.0.0.2:5000", "GET", f"/api/v1/printers/1/camera/stream?token={TOKEN}", "1.1", 200),
  61. )
  62. QueryTokenRedactFilter().filter(record)
  63. assert record.getMessage() == (
  64. '10.0.0.2:5000 - "GET /api/v1/printers/1/camera/stream?token=[REDACTED] HTTP/1.1" 200'
  65. )
  66. def test_message_without_arguments(self):
  67. record = _record(f"GET /api/v1/ws?token={TOKEN}", None)
  68. QueryTokenRedactFilter().filter(record)
  69. assert record.getMessage() == "GET /api/v1/ws?token=[REDACTED]"
  70. def test_through_a_real_logger_and_handler(self):
  71. """Attached to the logger, it covers whatever handler writes the line."""
  72. logger = logging.getLogger("test.query_token_redaction")
  73. stream = io.StringIO()
  74. handler = logging.StreamHandler(stream)
  75. logger.addHandler(handler)
  76. logger.addFilter(QueryTokenRedactFilter())
  77. logger.setLevel(logging.INFO)
  78. logger.propagate = False
  79. try:
  80. logger.info('%s - "WebSocket %s" [accepted]', "fe80::1:0", f"/api/v1/ws?token={TOKEN}")
  81. finally:
  82. logger.removeHandler(handler)
  83. assert TOKEN not in stream.getvalue()
  84. assert "token=[REDACTED]" in stream.getvalue()
  85. def test_main_attaches_the_filter_to_both_uvicorn_loggers():
  86. import backend.app.main # noqa: F401 -- importing configures logging
  87. for name in ("uvicorn.access", "uvicorn.error"):
  88. assert any(isinstance(f, QueryTokenRedactFilter) for f in logging.getLogger(name).filters), name
  89. def test_support_bundle_sanitizer_masks_tokens():
  90. line = f'INFO: 192.168.255.4:0 - "WebSocket /api/v1/ws?token={TOKEN}" [accepted]'
  91. assert TOKEN not in sanitize_log_content(line)
  92. assert "token=[REDACTED]" in sanitize_log_content(line)