diff --git a/src/clj_http/client.clj b/src/clj_http/client.clj index c56d77b1..7778468c 100644 --- a/src/clj_http/client.clj +++ b/src/clj_http/client.clj @@ -237,13 +237,47 @@ [{:keys [status]}] (<= 500 status 599)) +(def default-redact-headers + "Header names (lower-cased) whose values are redacted in thrown exceptions by + default, to keep credentials out of logs." + #{"authorization" "proxy-authorization"}) + +(defn- redact-headers-map + "Replace the values of any headers whose (lower-cased) name is in `redact` + with \"REDACTED\". Preserves the original header-map type and key casing." + [headers redact] + (if (and (seq headers) (seq redact)) + (reduce (fn [hs k] + (if (and (string? k) (contains? redact (str/lower-case k))) + (assoc hs k "REDACTED") + hs)) + headers (keys headers)) + headers)) + +(defn- redact-exception-data + "Redact sensitive headers in the response and the attached request before the + map is embedded in a thrown exception. Controlled by the request's + :redact-headers option (a set of header names), defaulting to + `default-redact-headers`. Pass `#{}` to disable." + [req resp] + (let [redact (set (map str/lower-case + (clojure.core/get req :redact-headers + default-redact-headers)))] + (cond-> resp + (:headers resp) + (update :headers redact-headers-map redact) + + (get-in resp [:request :headers]) + (update-in [:request :headers] redact-headers-map redact)))) + (defn- exceptions-response [req {:keys [status] :as resp}] (if (unexceptional-status-for-request? req status) resp (if (false? (opt req :throw-exceptions)) resp - (let [data (assoc resp :type ::unexceptional-status)] + (let [resp (redact-exception-data req resp) + data (assoc resp :type ::unexceptional-status)] (if (opt req :throw-entire-message) (throw+ data "clj-http: status %d %s" (:status %) resp) (throw+ data "clj-http: status %s" (:status %))))))) @@ -251,7 +285,15 @@ (defn wrap-exceptions "Middleware that throws a slingshot exception if the response is not a regular response. If :throw-entire-message? is set to true, the entire - response is used as the message, instead of just the status number." + response is used as the message, instead of just the status number. + + The thrown exception carries the response and the originating request, which + may include credentials (e.g. an Authorization header). Headers whose + lower-cased name is in the request's :redact-headers option have their values + replaced with \"REDACTED\" in both the response and request headers before the + exception is thrown. :redact-headers defaults to `default-redact-headers` + (authorization, proxy-authorization); pass your own set to override it, or + `#{}` to disable redaction." [client] (fn ([req] diff --git a/test/clj_http/test/client_test.clj b/test/clj_http/test/client_test.clj index f2b488d3..6926715d 100644 --- a/test/clj_http/test/client_test.clj +++ b/test/clj_http/test/client_test.clj @@ -622,6 +622,42 @@ (catch Object _ (is false ":type selector was not caught."))))) +(deftest redact-headers-in-exceptions + (testing "request Authorization header is redacted by default (#290)" + (let [client (fn [req] {:status 500 :request req}) + e-client (client/wrap-exceptions client)] + (try+ + (e-client {:headers {"Authorization" "Bearer secret" "X-Ok" "fine"}}) + (is false "should have thrown") + (catch map? data + (is (= "REDACTED" (get-in data [:request :headers "Authorization"]))) + (is (= "fine" (get-in data [:request :headers "X-Ok"]))))))) + (testing "default also redacts proxy-authorization" + (let [client (fn [req] {:status 500 :request req}) + e-client (client/wrap-exceptions client)] + (try+ + (e-client {:headers {"proxy-authorization" "Basic zzz"}}) + (catch map? data + (is (= "REDACTED" (get-in data [:request :headers "proxy-authorization"]))))))) + (testing "custom :redact-headers redacts the named response headers" + (let [client (fn [req] {:status 500 + :headers {"x-secret" "v" "content-type" "text/plain"}}) + e-client (client/wrap-exceptions client)] + (try+ + (e-client {:redact-headers #{"x-secret"}}) + (catch map? data + (is (= "REDACTED" (get-in data [:headers "x-secret"]))) + (is (= "text/plain" (get-in data [:headers "content-type"]))))))) + (testing ":redact-headers #{} disables redaction" + (let [client (fn [req] {:status 500 :request req}) + e-client (client/wrap-exceptions client)] + (try+ + (e-client {:redact-headers #{} + :headers {"Authorization" "Bearer secret"}}) + (catch map? data + (is (= "Bearer secret" + (get-in data [:request :headers "Authorization"])))))))) + (deftest throw-on-exceptional-async (let [client (fn [req respond raise] (try