response_redaction.py 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. """A refused header's value masked in every JSON response.
  2. An HTTP client refusing a header quotes the header's value, and the value is
  3. usually a credential: a stored token saved with a stray space or line break.
  4. That error text reaches the API from many places -- a provider's stored error,
  5. a backup or restore result, a connection test, a status endpoint, rows stored
  6. before any of them masked it -- so it is masked once, where every JSON body
  7. leaves, rather than at each place that may hand one on. Logs are masked by
  8. ``logging_filters.RefusedHeaderRedactFilter``.
  9. """
  10. from __future__ import annotations
  11. import re
  12. from starlette.datastructures import MutableHeaders
  13. from starlette.types import ASGIApp, Message, Receive, Scope, Send
  14. _MARKER = b"Illegal header "
  15. # Inside a JSON string: masked up to the string's closing quote, so the body
  16. # stays valid JSON.
  17. _REFUSED_HEADER_IN_JSON = re.compile(rb'(Illegal header (?:value|name) )(?:[^"\\]|\\.)*')
  18. def redact_refused_headers_json(body: bytes) -> bytes:
  19. """*body*, a JSON document, with every quoted refused header masked."""
  20. if _MARKER not in body:
  21. return body
  22. return _REFUSED_HEADER_IN_JSON.sub(rb"\1[REDACTED]", body)
  23. class RefusedHeaderResponseMask:
  24. """ASGI middleware masking refused headers in JSON response bodies.
  25. Install it innermost (added before any other middleware): outer
  26. ``@app.middleware`` layers re-stream a body in chunks. A body with a
  27. Content-Length is collected and masked whole, with the length fixed up; a
  28. streamed one is masked chunk by chunk.
  29. """
  30. def __init__(self, app: ASGIApp) -> None:
  31. self.app = app
  32. async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
  33. if scope["type"] != "http":
  34. await self.app(scope, receive, send)
  35. return
  36. start: Message | None = None
  37. sized = False
  38. chunks: list[bytes] = []
  39. async def masking_send(message: Message) -> None:
  40. nonlocal start, sized
  41. if message["type"] == "http.response.start":
  42. headers = MutableHeaders(raw=message["headers"])
  43. media_type = headers.get("content-type", "").split(";", 1)[0].strip().lower()
  44. if not (media_type == "application/json" or media_type.endswith("+json")):
  45. await send(message)
  46. return
  47. start = message
  48. sized = "content-length" in headers
  49. if not sized:
  50. await send(start)
  51. return
  52. if start is None or message["type"] != "http.response.body":
  53. await send(message)
  54. return
  55. if not sized:
  56. await send({**message, "body": redact_refused_headers_json(message.get("body", b""))})
  57. return
  58. chunks.append(message.get("body", b""))
  59. if message.get("more_body", False):
  60. return
  61. original = b"".join(chunks)
  62. body = redact_refused_headers_json(original)
  63. if body is not original:
  64. # Only a masked body changes length; a HEAD response's empty
  65. # body keeps the length of the GET body it stands for.
  66. MutableHeaders(raw=start["headers"])["content-length"] = str(len(body))
  67. await send(start)
  68. await send({"type": "http.response.body", "body": body, "more_body": False})
  69. await self.app(scope, receive, masking_send)