test_refused_header_response_mask.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. """A refused header's value never leaves the API in a JSON response.
  2. An HTTP client refusing a header (a stored token with a stray space or line
  3. break) quotes the value in its error, and that text reaches responses from many
  4. places: stored provider and backup errors (including rows written before this
  5. was masked anywhere), restore and connection-test results, status endpoints.
  6. ``RefusedHeaderResponseMask`` masks it in every JSON body on the way out.
  7. """
  8. from __future__ import annotations
  9. import json
  10. import pytest
  11. from fastapi import FastAPI, HTTPException
  12. from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse
  13. from httpx import ASGITransport, AsyncClient
  14. from backend.app.core.response_redaction import RefusedHeaderResponseMask, redact_refused_headers_json
  15. # Made up. Never paste a value from a real log here.
  16. SECRET = "resp-value-5"
  17. QUOTE = f"Illegal header value b'Bearer {SECRET} '"
  18. def _app(manifest=None) -> FastAPI:
  19. app = FastAPI()
  20. @app.api_route("/manifest.json", methods=["GET", "HEAD"])
  21. async def manifest_file():
  22. return FileResponse(manifest, media_type="application/manifest+json")
  23. @app.get("/model")
  24. async def model():
  25. return {"last_error": QUOTE, "other": "kept", "n": 3}
  26. @app.get("/nested")
  27. async def nested():
  28. return [{"message": f"LocalProtocolError: {QUOTE}"}, {"message": f'with "quotes" {QUOTE}'}]
  29. @app.get("/raised")
  30. async def raised():
  31. raise HTTPException(status_code=502, detail=f"Could not reach it: {QUOTE}")
  32. @app.get("/explicit")
  33. async def explicit():
  34. return JSONResponse({"error": QUOTE}, status_code=400)
  35. @app.get("/text")
  36. async def text():
  37. return PlainTextResponse(QUOTE)
  38. @app.get("/stream")
  39. async def stream():
  40. async def body():
  41. yield b'{"error": "'
  42. yield QUOTE.encode()
  43. yield b'"}'
  44. return StreamingResponse(body(), media_type="application/json")
  45. app.add_middleware(RefusedHeaderResponseMask)
  46. return app
  47. async def _get(path: str):
  48. async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client:
  49. return await client.get(path)
  50. @pytest.mark.asyncio
  51. async def test_a_head_request_keeps_the_length_of_the_body_it_omits(tmp_path):
  52. """HEAD sends no body; the length still describes the GET body."""
  53. manifest = tmp_path / "manifest.json"
  54. manifest.write_text('{"name": "Bambuddy"}')
  55. async with AsyncClient(transport=ASGITransport(app=_app(manifest)), base_url="http://test") as client:
  56. head = await client.head("/manifest.json")
  57. get = await client.get("/manifest.json")
  58. assert head.headers["content-length"] == get.headers["content-length"] == str(len('{"name": "Bambuddy"}'))
  59. @pytest.mark.asyncio
  60. @pytest.mark.parametrize("path", ["/model", "/nested", "/raised", "/explicit"])
  61. async def test_json_responses_carry_no_header_value(path):
  62. response = await _get(path)
  63. assert SECRET not in response.text
  64. assert "Illegal header value [REDACTED]" in response.text
  65. json.loads(response.text) # still valid JSON
  66. assert int(response.headers["content-length"]) == len(response.content)
  67. @pytest.mark.asyncio
  68. async def test_the_rest_of_the_body_is_kept():
  69. body = (await _get("/model")).json()
  70. assert body == {"last_error": "Illegal header value [REDACTED]", "other": "kept", "n": 3}
  71. @pytest.mark.asyncio
  72. async def test_a_streamed_json_body_is_masked_too():
  73. response = await _get("/stream")
  74. assert SECRET not in response.text
  75. @pytest.mark.asyncio
  76. async def test_other_content_types_pass_untouched():
  77. """Only JSON is the API's; a text body is left as its route wrote it."""
  78. assert (await _get("/text")).text == QUOTE
  79. @pytest.mark.parametrize(
  80. ("body", "expected"),
  81. [
  82. (b'{"a": "x Illegal header name b\'X-1\' y", "b": 1}', b'{"a": "x Illegal header name [REDACTED]", "b": 1}'),
  83. (b'{"a": "Illegal header value b\'\\"q\\" s\'"}', b'{"a": "Illegal header value [REDACTED]"}'),
  84. (b'{"a": "nothing here"}', b'{"a": "nothing here"}'),
  85. ],
  86. )
  87. def test_the_mask_ends_with_the_json_string(body, expected):
  88. assert redact_refused_headers_json(body) == expected
  89. def test_main_installs_the_mask_next_to_the_routes():
  90. """Innermost: outer middleware re-stream the body in chunks."""
  91. from backend.app.main import app
  92. assert app.user_middleware[-1].cls is RefusedHeaderResponseMask