From bf7168c7b36a44f052581e79516780a1e276e37f Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Wed, 8 Jul 2026 17:33:36 +0000 Subject: [PATCH] Redact sensitive header values when displaying Headers collections Printing a Headers collection (REPL, at-show, logging, stacktraces) used the default AbstractVector show and leaked raw values of sensitive headers such as Authorization. Request/Response display already masks these via _HTTP_REDACTED_HEADERS; add Base.show methods for Headers that route values through the same _http_render_header_value redactor, restoring the HTTP.jl 1.x behavior of masking Authorization, Proxy-Authorization, Cookie, and Set-Cookie with "******". Redaction is display-only: header(), iteration, and indexing still return the real values, and wire output is unaffected. Co-Authored-By: Claude Code --- src/http_display.jl | 28 +++++++++++++++++++++++++ test/http_core_tests.jl | 46 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/http_display.jl b/src/http_display.jl index 272880c45..67760c958 100644 --- a/src/http_display.jl +++ b/src/http_display.jl @@ -193,6 +193,34 @@ function _body_summary_label(body, content_length::Int64)::String return total > 0 ? string(total, "-byte body") : string(typeof(body), " body") end +function _show_redacted_header_pair(io::IO, key::String, value::String)::Nothing + show(io, key) + print(io, " => ") + show(io, _http_render_header_value(key, value)) + return nothing +end + +function Base.show(io::IO, headers::Headers) + print(io, "HTTP.Headers([") + for (i, (key, value)) in enumerate(headers) + i > 1 && print(io, ", ") + _show_redacted_header_pair(io, key, value) + end + print(io, "])") +end + +function Base.show(io::IO, ::MIME"text/plain", headers::Headers) + summary(io, headers) + isempty(headers) && return + print(io, ":") + key_width = maximum(length(repr(key)) for (key, _) in headers) + for (key, value) in headers + print(io, "\n ", lpad(repr(key), key_width), " => ") + show(io, _http_render_header_value(key, value)) + end + return +end + @inline function _request_summary_target(request::Request)::String request.host === nothing || return string(request.host::String, request.target) return request.target diff --git a/test/http_core_tests.jl b/test/http_core_tests.jl index 1b22aab82..07ddfa4a8 100644 --- a/test/http_core_tests.jl +++ b/test/http_core_tests.jl @@ -290,6 +290,52 @@ end @test occursin("elapsed", msg) end +@testset "HTTP core headers display" begin + headers = HT.Headers([ + "Authorization" => "Bearer super-secret", + "Proxy-Authorization" => "Basic dXNlcjpwYXNz", + "Cookie" => "session=secret-cookie", + "Set-Cookie" => "id=secret-id", + "Content-Type" => "text/plain", + ]) + + # Sensitive header values must be masked in both the compact (2-arg) and + # text/plain (3-arg) show forms, so they never leak into stacktraces, + # logging output, or the REPL. + compact = sprint(show, headers) + @test occursin("HTTP.Headers([", compact) + @test occursin("\"Authorization\" => \"******\"", compact) + @test occursin("\"Proxy-Authorization\" => \"******\"", compact) + @test occursin("\"Cookie\" => \"******\"", compact) + @test occursin("\"Set-Cookie\" => \"******\"", compact) + @test occursin("\"Content-Type\" => \"text/plain\"", compact) + @test !occursin("secret", compact) + @test !occursin("dXNlcjpwYXNz", compact) + + plain = sprint(io -> show(io, MIME"text/plain"(), headers)) + @test occursin("5-element", plain) + @test occursin("\"Authorization\" => \"******\"", plain) + @test occursin("\"Proxy-Authorization\" => \"******\"", plain) + @test occursin("\"Cookie\" => \"******\"", plain) + @test occursin("\"Set-Cookie\" => \"******\"", plain) + @test occursin("\"Content-Type\" => \"text/plain\"", plain) + @test !occursin("secret", plain) + @test !occursin("dXNlcjpwYXNz", plain) + + # Redaction is case-insensitive. + lower = HT.Headers() + push!(lower.entries, "authorization" => "Bearer raw-secret") + @test occursin("\"authorization\" => \"******\"", sprint(show, lower)) + @test !occursin("raw-secret", sprint(io -> show(io, MIME"text/plain"(), lower))) + + # Redaction is display-only; programmatic access returns the real value. + @test HT.header(headers, "Authorization") == "Bearer super-secret" + + # Empty collections render without a trailing colon or entries. + @test sprint(show, HT.Headers()) == "HTTP.Headers([])" + @test endswith(sprint(io -> show(io, MIME"text/plain"(), HT.Headers())), "0-element HTTP.Headers") +end + @testset "HTTP core request/response display" begin request_headers = HT.Headers() HT.setheader(request_headers, "Authorization", "Bearer super-secret")