Skip to content

Commit 60eba6f

Browse files
committed
Merge TASK-057: redact credentials in http_request::operator<<
Default-redact user:/pass:/Authorization-header values and cookies in http_request::operator<< output (CWE-312/CWE-532). Verbose form is opt-in via create_webserver::expose_credentials_in_logs(true) for development. Conflicts: test/Makefile.am check_PROGRAMS list — kept both http_request_operator_stream (TASK-057) and v2_dispatch_contract (TASK-053, just merged) alongside the auth_handler_* entries.
2 parents b654faa + e174de5 commit 60eba6f

15 files changed

Lines changed: 458 additions & 12 deletions

RELEASE_NOTES.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,17 @@ and see the v2 replacement.
209209
on the builder. Configured `internal_error_handler` callbacks are
210210
unaffected — they still receive the message and can build any body
211211
they want.
212+
- **`http_request::operator<<` redacts credentials by default.**
213+
v1 (and earlier v2 builds) emitted `pass:"<plaintext>"`,
214+
`digested_pass:"<plaintext>"`, `Authorization`/`Proxy-Authorization`
215+
header values, and cookie values verbatim into diagnostic output,
216+
leaking every credential into any log aggregation pipeline that
217+
captures operator-stream dumps (CWE-312, CWE-532, OWASP A09:2021).
218+
v2.0 replaces those fields with the fixed token `<redacted>` in the
219+
default stream output. To restore the v1 verbose form for local
220+
development, call `.expose_credentials_in_logs(true)` on the
221+
`create_webserver` builder — this flag is intended for development
222+
only and must not be set in production deployments.
212223

213224
## Threading
214225

specs/tasks/v2-deferred-backlog-plan.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,13 @@ production deploy leaks every Basic-auth password into their log
304304
aggregation pipeline.
305305

306306
**Action Items:**
307-
- [ ] Replace the literal password emission with a fixed redaction token
307+
- [x] Replace the literal password emission with a fixed redaction token
308308
(`pass:"<redacted>"`). Same treatment for `digested_pass` and any
309309
other authentication secret on the stream.
310-
- [ ] Add an opt-in `webserver_builder.expose_credentials_in_logs(true)`
310+
- [x] Add an opt-in `webserver_builder.expose_credentials_in_logs(true)`
311311
flag for the rare developer who needs the verbose form locally.
312-
- [ ] Update Doxygen on the operator to call out the redaction policy.
313-
- [ ] Add a unit test
312+
- [x] Update Doxygen on the operator to call out the redaction policy.
313+
- [x] Add a unit test
314314
`http_request_test::operator_stream_redacts_credentials`.
315315

316316
**Dependencies:**
@@ -327,6 +327,8 @@ aggregation pipeline.
327327
**Related Findings:** task-019 #22
328328
**Related Decisions:** none new; A09:2021
329329

330+
**Status:** Done
331+
330332
---
331333

332334
## TASK-058 — Hot-path allocation pass

specs/unworked_review_issues/2026-05-30_235001_task-057.md

Lines changed: 113 additions & 0 deletions
Large diffs are not rendered by default.

src/create_test_request.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ http_request create_test_request::build() {
103103
req.impl_->tls_enabled_local = _tls_enabled;
104104
#endif // HAVE_GNUTLS
105105

106+
// TASK-057: propagate the test-builder opt-in into the impl. The
107+
// webserver-dispatch path does the equivalent assignment in
108+
// webserver_impl::requests_answer_first_step.
109+
req.set_expose_credentials_in_logs(_expose_credentials_in_logs);
110+
106111
return req;
107112
}
108113

src/detail/webserver_body_pipeline.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ MHD_Result webserver_impl::requests_answer_first_step(MHD_Connection* connection
123123
// site but explicit within the constructor. (spec-alignment-checker-iter1-2/3)
124124
mr->dhr.reset(new http_request(connection, parent->unescaper));
125125
mr->dhr->set_file_cleanup_callback(parent->file_cleanup_callback);
126+
// TASK-057: propagate the redaction-bypass bit so operator<< honours
127+
// the builder opt-in for every request the webserver dispatches.
128+
mr->dhr->set_expose_credentials_in_logs(parent->expose_credentials_in_logs);
126129

127130
// TASK-047 -- request_received hook. Fires after the http_request is
128131
// populated but before any body bytes are read (and before any

src/http_request.cpp

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -362,17 +362,90 @@ void http_request::set_file_cleanup_callback(file_cleanup_callback_ptr callback)
362362
impl_->file_cleanup_callback_ = callback;
363363
}
364364

365+
void http_request::set_expose_credentials_in_logs(bool v) {
366+
impl_->expose_credentials_in_logs_ = v;
367+
}
368+
369+
namespace {
370+
371+
// TASK-057: Authorization-class header names whose values carry
372+
// credential material (Basic / Digest / Bearer payloads). Matched
373+
// case-insensitively against the keys in the Headers / Footers maps
374+
// so that the redaction policy is robust to header-case variation.
375+
bool is_authorization_header_key(std::string_view key) noexcept {
376+
constexpr std::string_view kAuth = "Authorization";
377+
constexpr std::string_view kProxyAuth = "Proxy-Authorization";
378+
if (key.size() != kAuth.size() && key.size() != kProxyAuth.size()) {
379+
return false;
380+
}
381+
auto ieq = [](std::string_view a, std::string_view b) noexcept {
382+
if (a.size() != b.size()) return false;
383+
for (std::size_t i = 0; i < a.size(); ++i) {
384+
const unsigned char ca = static_cast<unsigned char>(a[i]);
385+
const unsigned char cb = static_cast<unsigned char>(b[i]);
386+
if (std::tolower(ca) != std::tolower(cb)) return false;
387+
}
388+
return true;
389+
};
390+
return ieq(key, kAuth) || ieq(key, kProxyAuth);
391+
}
392+
393+
constexpr std::string_view kRedacted = "<redacted>";
394+
395+
// TASK-057: emit a Headers/Footers map with Authorization-class header
396+
// values replaced by the fixed redaction token. Mirrors the wire shape
397+
// of http::dump_header_map(header_view_map) for non-authorization
398+
// entries so the on-the-wire diagnostic format is unchanged.
399+
void dump_header_map_redacted(std::ostream& os, const std::string& prefix,
400+
const http::header_view_map& map) {
401+
if (map.empty()) return;
402+
os << " " << prefix << " [";
403+
for (const auto& kv : map) {
404+
os << kv.first << ":\"";
405+
if (is_authorization_header_key(kv.first)) {
406+
os << kRedacted;
407+
} else {
408+
os << kv.second;
409+
}
410+
os << "\" ";
411+
}
412+
os << "]" << std::endl;
413+
}
414+
415+
// TASK-057: cookie values are credential material by default (session
416+
// IDs, CSRF tokens, JWTs); redaction is unconditional on the keys
417+
// (which remain visible for log triage).
418+
void dump_cookie_map_redacted(std::ostream& os, const std::string& prefix,
419+
const http::header_view_map& map) {
420+
if (map.empty()) return;
421+
os << " " << prefix << " [";
422+
for (const auto& kv : map) {
423+
os << kv.first << ":\"" << kRedacted << "\" ";
424+
}
425+
os << "]" << std::endl;
426+
}
427+
428+
} // namespace
429+
365430
std::ostream& operator<< (std::ostream& os, const http_request& r) {
431+
const bool expose = r.impl_->expose_credentials_in_logs_;
366432
os << r.get_method() << " Request [";
367-
// TASK-034: get_user/get_pass are unconditional; they return empty
368-
// on HAVE_BAUTH-off builds, so the dump prints two empty quoted
369-
// strings in that case (harmless).
370-
os << "user:\"" << r.get_user() << "\" pass:\"" << r.get_pass() << "\"";
433+
if (expose) {
434+
os << "user:\"" << r.get_user() << "\" pass:\"" << r.get_pass() << "\"";
435+
} else {
436+
os << "user:\"" << r.get_user() << "\" pass:\"" << kRedacted << "\"";
437+
}
371438
os << "] path:\"" << r.get_path() << "\"" << std::endl;
372439

373-
http::dump_header_map(os, "Headers", r.get_headers());
374-
http::dump_header_map(os, "Footers", r.get_footers());
375-
http::dump_header_map(os, "Cookies", r.get_cookies());
440+
if (expose) {
441+
http::dump_header_map(os, "Headers", r.get_headers());
442+
http::dump_header_map(os, "Footers", r.get_footers());
443+
http::dump_header_map(os, "Cookies", r.get_cookies());
444+
} else {
445+
dump_header_map_redacted(os, "Headers", r.get_headers());
446+
dump_header_map_redacted(os, "Footers", r.get_footers());
447+
dump_cookie_map_redacted(os, "Cookies", r.get_cookies());
448+
}
376449
http::dump_arg_map(os, "Query Args", r.get_args());
377450

378451
os << " Version [ " << r.get_version() << " ] Requestor [ " << r.get_requestor()

src/httpserver/create_test_request.hpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ class create_test_request {
120120
return *this;
121121
}
122122

123+
// TASK-057: opt out of the default credential-redaction policy in
124+
// http_request::operator<<. Mirrors the webserver-side builder
125+
// setter @ref create_webserver::expose_credentials_in_logs for the
126+
// unit-test scope (no webserver construction).
127+
create_test_request& expose_credentials_in_logs(bool enable = true) {
128+
_expose_credentials_in_logs = enable;
129+
return *this;
130+
}
131+
123132
http_request build();
124133

125134
private:
@@ -142,6 +151,10 @@ class create_test_request {
142151
std::string _requestor = "127.0.0.1";
143152
uint16_t _requestor_port = 0;
144153
bool _tls_enabled = false;
154+
// TASK-057: default false (secure-by-default). When true, build()
155+
// sets http_request_impl::expose_credentials_in_logs_ so the
156+
// diagnostic dump streams the v1 verbose form.
157+
bool _expose_credentials_in_logs = false;
145158
};
146159

147160
} // namespace httpserver

src/httpserver/create_webserver.hpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,34 @@ class create_webserver {
283283
_expose_exception_messages = enable; return *this;
284284
}
285285

286+
/**
287+
* Restore the v1 / pre-TASK-057 behaviour of streaming credential
288+
* material verbatim from @ref httpserver::operator<<(std::ostream&, const http_request&).
289+
*
290+
* @warning CWE-312 / CWE-532 (OWASP A09:2021): when this flag is
291+
* enabled and the dump is routed to a log aggregator
292+
* (`log_access`, `log_error`, stdout-capturing systemd, or
293+
* a centralised syslog / SIEM pipeline), every Basic-auth
294+
* password, every Authorization / Proxy-Authorization
295+
* header value, and every cookie / session token is
296+
* written in plaintext to the log store. Enable only in
297+
* development or behind an explicit `#ifndef NDEBUG`
298+
* guard.
299+
*
300+
* Default is `false`: `pass`, `Authorization`, `Proxy-Authorization`,
301+
* and every cookie value are streamed as the fixed token
302+
* `"<redacted>"`. The username (`user:"..."`) is never redacted —
303+
* it is an identifier, not a secret (REMOTE_USER access-log
304+
* convention).
305+
*
306+
* @param enable `true` to expose credentials in diagnostic dumps
307+
* (dev only), `false` for the default redaction.
308+
* @return reference to this builder for chaining.
309+
*/
310+
create_webserver& expose_credentials_in_logs(bool enable = true) {
311+
_expose_credentials_in_logs = enable; return *this;
312+
}
313+
286314
create_webserver& https_mem_key(const std::string& v) { _https_mem_key = http::load_file(v); return *this; }
287315
create_webserver& https_mem_cert(const std::string& v) { _https_mem_cert = http::load_file(v); return *this; }
288316
create_webserver& https_mem_trust(const std::string& v) { _https_mem_trust = http::load_file(v); return *this; }
@@ -569,6 +597,11 @@ class create_webserver {
569597
// true, internal_error_page surfaces the originating exception's
570598
// message in the default 500 body (development-only behaviour).
571599
bool _expose_exception_messages = false;
600+
// TASK-057: default false (CWE-312 / CWE-532 fix). When true,
601+
// http_request::operator<< restores the v1 verbose form for the
602+
// four credential surfaces (pass, Authorization /
603+
// Proxy-Authorization header values, cookie values).
604+
bool _expose_credentials_in_logs = false;
572605
std::string _https_mem_key;
573606
std::string _https_mem_cert;
574607
std::string _https_mem_trust;

src/httpserver/detail/http_request_impl.hpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,15 @@ class http_request_impl {
250250
mutable std::vector<std::string> path_pieces_public_;
251251
mutable bool path_pieces_public_built_ = false;
252252

253+
// TASK-057: when true, http_request::operator<< streams credential
254+
// material verbatim (v1 verbose form). Default false: the four
255+
// credential surfaces (pass, Authorization / Proxy-Authorization
256+
// header values, all cookie values) are replaced by the fixed
257+
// token "<redacted>". Plumbed from webserver::expose_credentials_in_logs
258+
// via webserver_impl::requests_answer_first_step, or directly via
259+
// create_test_request::expose_credentials_in_logs() for unit tests.
260+
bool expose_credentials_in_logs_ = false;
261+
253262
#ifdef HAVE_GNUTLS
254263
// TASK-019: cache fields for the high-level cert accessors. The two
255264
// time fields are spelled std::int64_t (not std::time_t) so they

src/httpserver/http_request.hpp

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,12 +525,52 @@ class http_request {
525525

526526
void set_file_cleanup_callback(file_cleanup_callback_ptr callback);
527527

528+
// TASK-057: set the redaction-bypass bit for diagnostic streaming.
529+
// Plumbed from webserver::expose_credentials_in_logs at dispatch
530+
// time and from create_test_request::build() for unit tests.
531+
void set_expose_credentials_in_logs(bool v);
532+
528533
friend class webserver;
529534
friend class detail::webserver_impl; // TASK-014: PIMPL dispatch path
530535
friend struct detail::modded_request;
531536
friend class create_test_request; // TASK-015: test builder accesses impl_
532537
};
533538

539+
/**
540+
* Stream-insert a human-readable dump of @p r into @p os for diagnostic
541+
* logging.
542+
*
543+
* @section redaction Redaction policy (TASK-057, OWASP A09:2021 / CWE-312 / CWE-532)
544+
* By default the following fields are emitted as the fixed token
545+
* `"<redacted>"` instead of their plaintext values:
546+
* - The Basic-auth password (`pass:"<redacted>"`)
547+
* - The `Authorization` and `Proxy-Authorization` request headers
548+
* (and the same names in trailers / footers), case-insensitive
549+
* - Every cookie value (cookie keys are preserved for log triage)
550+
*
551+
* The username (`user:"..."`) is NOT redacted — it follows the REMOTE_USER
552+
* access-log convention; it is an identifier, not a secret.
553+
*
554+
* Query-string arguments are streamed verbatim. Callers that put
555+
* credential material in query parameters (`?token=...`) should
556+
* sanitize before constructing the request URL; see the warning in
557+
* the class-level block of @ref http_request.
558+
*
559+
* @section opt_in Restoring the verbose v1 form
560+
* Call `create_webserver::expose_credentials_in_logs(true)` on the
561+
* builder used to construct the parent @ref webserver. This restores
562+
* the pre-TASK-057 plaintext-everywhere behaviour for every
563+
* @ref http_request the webserver dispatches.
564+
*
565+
* @warning `expose_credentials_in_logs(true)` is DEVELOPMENT-ONLY.
566+
* When the dump is routed to a log aggregator (`log_access`,
567+
* `log_error`, stdout-capturing systemd, or a centralised
568+
* syslog/SIEM pipeline), enabling the flag in production
569+
* exposes every Basic-auth password, every Authorization
570+
* header, and every cookie/session token in plaintext to
571+
* anyone with read access to the log store. Guard with
572+
* `#ifndef NDEBUG` or an explicit environment check.
573+
*/
534574
std::ostream &operator<< (std::ostream &os, const http_request &r);
535575

536576
} // namespace httpserver

0 commit comments

Comments
 (0)