| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384 |
- """A refused header's value masked in every JSON response.
- An HTTP client refusing a header quotes the header's value, and the value is
- usually a credential: a stored token saved with a stray space or line break.
- That error text reaches the API from many places -- a provider's stored error,
- a backup or restore result, a connection test, a status endpoint, rows stored
- before any of them masked it -- so it is masked once, where every JSON body
- leaves, rather than at each place that may hand one on. Logs are masked by
- ``logging_filters.RefusedHeaderRedactFilter``.
- """
- from __future__ import annotations
- import re
- from starlette.datastructures import MutableHeaders
- from starlette.types import ASGIApp, Message, Receive, Scope, Send
- _MARKER = b"Illegal header "
- # Inside a JSON string: masked up to the string's closing quote, so the body
- # stays valid JSON.
- _REFUSED_HEADER_IN_JSON = re.compile(rb'(Illegal header (?:value|name) )(?:[^"\\]|\\.)*')
- def redact_refused_headers_json(body: bytes) -> bytes:
- """*body*, a JSON document, with every quoted refused header masked."""
- if _MARKER not in body:
- return body
- return _REFUSED_HEADER_IN_JSON.sub(rb"\1[REDACTED]", body)
- class RefusedHeaderResponseMask:
- """ASGI middleware masking refused headers in JSON response bodies.
- Install it innermost (added before any other middleware): outer
- ``@app.middleware`` layers re-stream a body in chunks. A body with a
- Content-Length is collected and masked whole, with the length fixed up; a
- streamed one is masked chunk by chunk.
- """
- def __init__(self, app: ASGIApp) -> None:
- self.app = app
- async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if scope["type"] != "http":
- await self.app(scope, receive, send)
- return
- start: Message | None = None
- sized = False
- chunks: list[bytes] = []
- async def masking_send(message: Message) -> None:
- nonlocal start, sized
- if message["type"] == "http.response.start":
- headers = MutableHeaders(raw=message["headers"])
- media_type = headers.get("content-type", "").split(";", 1)[0].strip().lower()
- if not (media_type == "application/json" or media_type.endswith("+json")):
- await send(message)
- return
- start = message
- sized = "content-length" in headers
- if not sized:
- await send(start)
- return
- if start is None or message["type"] != "http.response.body":
- await send(message)
- return
- if not sized:
- await send({**message, "body": redact_refused_headers_json(message.get("body", b""))})
- return
- chunks.append(message.get("body", b""))
- if message.get("more_body", False):
- return
- original = b"".join(chunks)
- body = redact_refused_headers_json(original)
- if body is not original:
- # Only a masked body changes length; a HEAD response's empty
- # body keeps the length of the GET body it stands for.
- MutableHeaders(raw=start["headers"])["content-length"] = str(len(body))
- await send(start)
- await send({"type": "http.response.body", "body": body, "more_body": False})
- await self.app(scope, receive, masking_send)
|