-
-
Notifications
You must be signed in to change notification settings - Fork 191
Expand file tree
/
Copy pathauthentication.cpp
More file actions
1462 lines (1277 loc) · 57.5 KB
/
Copy pathauthentication.cpp
File metadata and controls
1462 lines (1277 loc) · 57.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
This file is part of libhttpserver
Copyright (C) 2011-2019 Sebastiano Merlino
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
USA
*/
#if defined(_WIN32) && !defined(__CYGWIN__)
#define _WINDOWS
#undef _WIN32_WINNT
#define _WIN32_WINNT 0x600
#include <winsock2.h>
#include <ws2tcpip.h>
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#endif
#include <curl/curl.h>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include "./httpserver.hpp"
#include "./littletest.hpp"
#include "./test_utils.hpp"
#include "./digest_client.hpp"
// v2-digest tracking note:
//
// The resources emit full RFC-7616 challenges via
// http_response::unauthorized(digest_challenge{...}). The
// tests drive the nonce/opaque/qop state machine themselves rather than
// delegating to libcurl's CURLAUTH_DIGEST automation, and every test below
// performs an explicit two-round flow:
// round 1: plain GET, expect 401 + RFC 7616 §3.3 challenge
// round 2: GET with hand-built `Authorization: Digest …` header
//
// This pins behaviours that libcurl previously masked:
// - For *_wrong_pass: the 401 fires on the second request (not just on
// the initial challenge), proving check_digest_auth* rejected the
// response token rather than the wire being short-circuited earlier.
// - For *_ha1_*: the test signs with a precomputed HA1 directly and
// never sends cleartext over the wire. The server returns 200 only if
// it validates against the configured HA1 (not by re-deriving
// MD5/SHA-256 from a password it never received). The wrong-HA1
// negative variants strengthen the proof: signing with an HA1 derived
// from a *different* password produces 401, even though the server is
// configured with the original HA1.
// - digest_user_cache_with_auth now reaches the cache-hit code path
// (digest_user_cache_resource was migrated to digest_challenge so
// the handshake can complete).
//
// Remaining gap (signal_stale re-challenge path):
// The NONCE_STALE branch inside digest_resource/digest_ha1_*_resource
// (signal_stale = true) cannot be reliably triggered in CI because MHD's
// nonce expiry requires a real time delay and concurrent replay
// requests. This gap remains open; it will be covered when full
// nonce-expiry testing infrastructure lands.
using std::shared_ptr;
using httpserver::webserver;
using httpserver::create_webserver;
using httpserver::http_response;
using httpserver::http_resource;
using httpserver::http_request;
size_t writefunc(void *ptr, size_t size, size_t nmemb, std::string *s) {
s->append(reinterpret_cast<char*>(ptr), size*nmemb);
return size*nmemb;
}
#ifdef HAVE_DAUTH
// Round-1 helper: issue a plain GET to `url` and return a pair of
// {http_status_code, raw_header_block}. Each caller asserts the expected 401
// via the LT_CHECK_EQ macro in its own test context so the assertion names
// are accurate. Used by every two-round digest-auth test.
static std::pair<long, std::string> // NOLINT(runtime/int)
collect_challenge(const char* url) {
std::string body, headers;
long http_code = 0; // NOLINT(runtime/int)
// No RAII wrapper: curl_easy_init/curl_easy_cleanup are synchronous C API
// calls that never throw, CURLOPT_NOSIGNAL avoids signal-based
// interruption, and every path below falls through to the unconditional
// curl_easy_cleanup(curl) at the end of this test-only helper.
CURL *curl = curl_easy_init();
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &headers);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 150L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 150L);
curl_easy_setopt(curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1);
curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
return {http_code, headers};
}
// Round-2 helper: issue a GET to `url` with an explicit `Authorization:`
// header string (the full "Authorization: Digest …" line), capture the
// response body in `*body_out`, and return the HTTP status code.
static long perform_with_auth_header( // NOLINT(runtime/int)
const char* url,
const char* auth_hdr,
std::string* body_out) {
long http_code = 0; // NOLINT(runtime/int)
// No RAII wrapper: curl_easy_init/curl_easy_cleanup are synchronous C API
// calls that never throw, CURLOPT_NOSIGNAL avoids signal-based
// interruption, and every path below falls through to the unconditional
// curl_easy_cleanup(curl) at the end of this test-only helper.
CURL *curl = curl_easy_init();
struct curl_slist *hdrs = nullptr;
hdrs = curl_slist_append(hdrs, auth_hdr);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, body_out);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 150L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 150L);
curl_easy_setopt(curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1);
curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
curl_slist_free_all(hdrs);
return http_code;
}
#endif // HAVE_DAUTH
#ifdef HAVE_BAUTH
class user_pass_resource : public http_resource {
public:
http_response render_get(const http_request& req) {
if (req.get_user() != "myuser" || req.get_pass() != "mypass") {
return http_response::unauthorized("Basic", "examplerealm", "FAIL");
}
return http_response::string(std::string(req.get_user()) + " " + std::string(req.get_pass()));
}
};
#endif // HAVE_BAUTH
#ifdef HAVE_DAUTH
// digest_resource drives the full RFC-7616 nonce/opaque
// handshake by emitting digest_challenge_response_body challenges. The first
// request from a curl --digest client arrives with no Authorization
// header (get_digested_user() empty), so we emit the initial RFC-7616
// challenge with nonce/opaque/algorithm/qop populated by the dispatch
// path; on the second request, curl recomputes the response using the
// challenge, and check_digest_auth() validates and returns OK.
class digest_resource : public http_resource {
public:
http_response render_get(const http_request& req) {
using httpserver::http::http_utils;
using httpserver::digest_challenge;
digest_challenge fail_ch{.realm = "examplerealm", .response_body = "FAIL"};
if (req.get_digested_user() == "") {
return http_response::unauthorized(fail_ch);
}
auto result = req.check_digest_auth("examplerealm", "mypass", 300,
0, http_utils::digest_algorithm::MD5);
if (result == http_utils::digest_auth_result::NONCE_STALE) {
auto stale_ch = fail_ch;
stale_ch.signal_stale = true;
return http_response::unauthorized(stale_ch);
}
if (result != http_utils::digest_auth_result::OK) {
return http_response::unauthorized(fail_ch);
}
return http_response::string("SUCCESS");
}
};
#endif // HAVE_DAUTH
LT_BEGIN_SUITE(authentication_suite)
void set_up() {
}
void tear_down() {
}
LT_END_SUITE(authentication_suite)
#ifdef HAVE_BAUTH
LT_BEGIN_AUTO_TEST(authentication_suite, base_auth)
webserver ws{create_webserver(0)};
auto user_pass = std::make_shared<user_pass_resource>();
ws.register_path("base", user_pass);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
std::string s;
CURL *curl = curl_easy_init();
CURLcode res;
curl_easy_setopt(curl, CURLOPT_USERNAME, "myuser");
curl_easy_setopt(curl, CURLOPT_PASSWORD, "mypass");
const std::string url = "localhost:" + std::to_string(port) + "/base";
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(s, "myuser mypass");
curl_easy_cleanup(curl);
ws.stop();
LT_END_AUTO_TEST(base_auth)
LT_BEGIN_AUTO_TEST(authentication_suite, base_auth_fail)
webserver ws{create_webserver(0)};
auto user_pass = std::make_shared<user_pass_resource>();
ws.register_path("base", user_pass);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
std::string s;
CURL *curl = curl_easy_init();
CURLcode res;
curl_easy_setopt(curl, CURLOPT_USERNAME, "myuser");
curl_easy_setopt(curl, CURLOPT_PASSWORD, "wrongpass");
const std::string url = "localhost:" + std::to_string(port) + "/base";
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(s, "FAIL");
curl_easy_cleanup(curl);
ws.stop();
LT_END_AUTO_TEST(base_auth_fail)
#endif // HAVE_BAUTH
// MinGW64's curl --digest parser does not accept the challenge produced by
// MHD's MHD_queue_auth_required_response3 on Windows, so the authenticated
// round-trip never completes. Predates the RFC-7616 work and is a
// test-infrastructure issue, not a digest-algorithm issue. The HAVE_DAUTH
// guard separately skips the block when libmicrohttpd is built without
// digest-auth support.
// reason: see test/PORTABILITY.md §authentication.cpp digest-auth block.
#if !defined(_WINDOWS) && defined(HAVE_DAUTH)
// Pre-computed MD5 hash of "myuser:examplerealm:mypass"
// printf "myuser:examplerealm:mypass" | md5sum
// 6ceef750e0130d6528b938c3abd94110
static const unsigned char PRECOMPUTED_HA1_MD5[16] = {
0x6c, 0xee, 0xf7, 0x50, 0xe0, 0x13, 0x0d, 0x65,
0x28, 0xb9, 0x38, 0xc3, 0xab, 0xd9, 0x41, 0x10
};
// Pre-computed SHA-256 hash of "myuser:examplerealm:mypass"
// printf "myuser:examplerealm:mypass" | sha256sum
// d4ff5b1795b23b4c625975959f3276526f3f4f4ef7d22083207e02d7c4bd8a05
static const unsigned char PRECOMPUTED_HA1_SHA256[32] = {
0xd4, 0xff, 0x5b, 0x17, 0x95, 0xb2, 0x3b, 0x4c,
0x62, 0x59, 0x75, 0x95, 0x9f, 0x32, 0x76, 0x52,
0x6f, 0x3f, 0x4f, 0x4e, 0xf7, 0xd2, 0x20, 0x83,
0x20, 0x7e, 0x02, 0xd7, 0xc4, 0xbd, 0x8a, 0x05
};
// A single parameterised resource replaces the
// former digest_ha1_md5_resource and digest_ha1_sha256_resource pair. The two
// classes were structurally identical and differed only in the algorithm enum,
// the precomputed HA1 constant, and the HA1 byte size. Collapsing them into
// one class means any change to the NONCE_STALE or NOT_AUTHORIZED branch
// only needs to be applied once.
class digest_ha1_resource : public http_resource {
public:
using http_utils = httpserver::http::http_utils;
digest_ha1_resource(http_utils::digest_algorithm algo,
const unsigned char* ha1,
std::size_t ha1_size)
: algo_(algo), ha1_(ha1), ha1_size_(ha1_size) {}
http_response render_get(const http_request& req) {
using httpserver::digest_challenge;
if (req.get_digested_user() == "") {
return http_response::unauthorized(
digest_challenge{.realm = "examplerealm",
.algorithm = algo_,
.response_body = "FAIL"});
}
auto result = req.check_digest_auth_digest("examplerealm", ha1_,
ha1_size_, 300, 0, algo_);
if (result == http_utils::digest_auth_result::NONCE_STALE) {
return http_response::unauthorized(
digest_challenge{.realm = "examplerealm",
.algorithm = algo_,
.signal_stale = true,
.response_body = "FAIL"});
} else if (result != http_utils::digest_auth_result::OK) {
return http_response::unauthorized(
digest_challenge{.realm = "examplerealm",
.algorithm = algo_,
.response_body = "FAIL"});
}
return http_response::string("SUCCESS");
}
private:
http_utils::digest_algorithm algo_;
const unsigned char* ha1_;
std::size_t ha1_size_;
};
// Two-round hand-rolled RFC 7616 §3.4 flow. Round 1 fetches the
// challenge, the test parses it and computes the response client-side,
// round 2 ships an explicit `Authorization: Digest …` header. Asserts the
// full handshake terminates in 200 SUCCESS.
LT_BEGIN_AUTO_TEST(authentication_suite, digest_auth)
webserver ws{create_webserver(0)
.digest_auth_random("myrandom")
.nonce_nc_size(300)};
auto digest = std::make_shared<digest_resource>();
ws.register_path("base", digest);
ws.start(false);
const uint16_t port = ws.get_bound_port();
// This curl_global_init() block is intentionally repeated per-test
// (rather than hoisted into set_up()) because curl_global_init() is
// idempotent and cheap to call redundantly; each test stays self-
// contained and independently readable. Do not flag this duplication
// again.
#if defined(_WINDOWS)
curl_global_init(CURL_GLOBAL_WIN32);
#else
curl_global_init(CURL_GLOBAL_ALL);
#endif
// Round 1: plain GET, capture the WWW-Authenticate challenge.
const std::string url1 = "localhost:" + std::to_string(port) + "/base";
auto [http_code1, headers1] = collect_challenge(url1.c_str());
LT_CHECK_EQ(http_code1, 401);
auto challenge = httpserver_test::extract_digest_challenge(headers1);
LT_ASSERT_EQ(challenge.has_value(), true);
LT_CHECK_EQ(challenge->qop, std::string("auth"));
// digest_resource emits algorithm=MD5 implicitly (default); assert it
// reached the wire, matching the pattern used in the HA1 sibling tests.
LT_CHECK_EQ(challenge->algorithm, std::string("MD5"));
// Round 2: hand-built Authorization header carrying our response.
std::string cnonce = httpserver_test::make_cnonce();
std::string response = httpserver_test::compute_response_cleartext(
*challenge, httpserver_test::digest_hash::md5,
"GET", "/base", "myuser", "mypass", cnonce, "00000001");
std::string auth_header = "Authorization: " +
httpserver_test::build_authorization_header(
*challenge, "myuser", "/base", cnonce, "00000001", response);
std::string body2;
const std::string url2 = "localhost:" + std::to_string(port) + "/base";
long http_code2 = perform_with_auth_header( // NOLINT(runtime/int)
url2.c_str(), auth_header.c_str(), &body2);
LT_CHECK_EQ(http_code2, 200);
LT_CHECK_EQ(body2, std::string("SUCCESS"));
ws.stop();
LT_END_AUTO_TEST(digest_auth)
// Wrong-password variant. The 401 fires on round 2 (after the
// client has computed a response with the wrong cleartext) -- under the
// earlier libcurl-driven shape the round-1 challenge alone could
// satisfy the body=="FAIL" assertion, but here we explicitly assert the
// rejection happens after the full handshake completes.
LT_BEGIN_AUTO_TEST(authentication_suite, digest_auth_wrong_pass)
webserver ws{create_webserver(0)
.digest_auth_random("myrandom")
.nonce_nc_size(300)};
auto digest = std::make_shared<digest_resource>();
ws.register_path("base", digest);
ws.start(false);
const uint16_t port = ws.get_bound_port();
#if defined(_WINDOWS)
curl_global_init(CURL_GLOBAL_WIN32);
#else
curl_global_init(CURL_GLOBAL_ALL);
#endif
// Round 1.
const std::string url1 = "localhost:" + std::to_string(port) + "/base";
auto [http_code1, headers1] = collect_challenge(url1.c_str());
LT_CHECK_EQ(http_code1, 401);
auto challenge = httpserver_test::extract_digest_challenge(headers1);
LT_ASSERT_EQ(challenge.has_value(), true);
// Round 2 -- sign with wrong password.
std::string cnonce = httpserver_test::make_cnonce();
std::string response = httpserver_test::compute_response_cleartext(
*challenge, httpserver_test::digest_hash::md5,
"GET", "/base", "myuser", "wrongpass", cnonce, "00000001");
std::string auth_header = "Authorization: " +
httpserver_test::build_authorization_header(
*challenge, "myuser", "/base", cnonce, "00000001", response);
std::string body2;
const std::string url2 = "localhost:" + std::to_string(port) + "/base";
// The 401 here is the round-2 server-side rejection: check_digest_auth()
// returned NOT_AUTHORIZED because the response token didn't match what
// the server computed from the configured password "mypass".
long http_code2 = perform_with_auth_header( // NOLINT(runtime/int)
url2.c_str(), auth_header.c_str(), &body2);
LT_CHECK_EQ(http_code2, 401);
LT_CHECK_EQ(body2, std::string("FAIL"));
ws.stop();
LT_END_AUTO_TEST(digest_auth_wrong_pass)
// HA1-precomputed MD5 path. The test never sends cleartext over
// the wire and never feeds CURLOPT_USERPWD: round 2 signs with the same
// 16-byte HA1 the server is configured with. A successful 200 proves the
// server is verifying against the configured HA1 (not by recomputing
// MD5("myuser:examplerealm:mypass") from cleartext it never received).
LT_BEGIN_AUTO_TEST(authentication_suite, digest_auth_with_ha1_md5)
webserver ws{create_webserver(0)
.digest_auth_random("myrandom")
.nonce_nc_size(300)};
auto digest_ha1 = std::make_shared<digest_ha1_resource>(
digest_ha1_resource::http_utils::digest_algorithm::MD5,
PRECOMPUTED_HA1_MD5, httpserver::http::http_utils::md5_digest_size);
ws.register_path("base", digest_ha1);
ws.start(false);
const uint16_t port = ws.get_bound_port();
#if defined(_WINDOWS)
curl_global_init(CURL_GLOBAL_WIN32);
#else
curl_global_init(CURL_GLOBAL_ALL);
#endif
// Round 1.
const std::string url1 = "localhost:" + std::to_string(port) + "/base";
auto [http_code1, headers1] = collect_challenge(url1.c_str());
LT_CHECK_EQ(http_code1, 401);
auto challenge = httpserver_test::extract_digest_challenge(headers1);
LT_ASSERT_EQ(challenge.has_value(), true);
// The resource emits algorithm=MD5; assert it reached the wire.
LT_CHECK_EQ(challenge->algorithm, std::string("MD5"));
// Round 2 -- sign with the precomputed HA1 directly.
std::string cnonce = httpserver_test::make_cnonce();
std::string response = httpserver_test::compute_response_ha1(
*challenge, httpserver_test::digest_hash::md5,
"GET", "/base", PRECOMPUTED_HA1_MD5, 16, cnonce, "00000001");
std::string auth_header = "Authorization: " +
httpserver_test::build_authorization_header(
*challenge, "myuser", "/base", cnonce, "00000001", response);
std::string body2;
const std::string url2 = "localhost:" + std::to_string(port) + "/base";
long http_code2 = perform_with_auth_header( // NOLINT(runtime/int)
url2.c_str(), auth_header.c_str(), &body2);
LT_CHECK_EQ(http_code2, 200);
LT_CHECK_EQ(body2, std::string("SUCCESS"));
ws.stop();
LT_END_AUTO_TEST(digest_auth_with_ha1_md5)
// HA1-precomputed MD5 negative variant. The test signs with an
// HA1 derived from a *different* password the server doesn't know about,
// so check_digest_auth_digest() rejects with 401. This proves the server-
// side validation pathway is HA1-driven (it cannot have re-derived the
// wrong HA1 from cleartext, because it never received cleartext).
LT_BEGIN_AUTO_TEST(authentication_suite, digest_auth_with_ha1_md5_wrong_pass)
webserver ws{create_webserver(0)
.digest_auth_random("myrandom")
.nonce_nc_size(300)};
auto digest_ha1 = std::make_shared<digest_ha1_resource>(
digest_ha1_resource::http_utils::digest_algorithm::MD5,
PRECOMPUTED_HA1_MD5, httpserver::http::http_utils::md5_digest_size);
ws.register_path("base", digest_ha1);
ws.start(false);
const uint16_t port = ws.get_bound_port();
#if defined(_WINDOWS)
curl_global_init(CURL_GLOBAL_WIN32);
#else
curl_global_init(CURL_GLOBAL_ALL);
#endif
// Round 1.
const std::string url1 = "localhost:" + std::to_string(port) + "/base";
auto [http_code1, headers1] = collect_challenge(url1.c_str());
LT_CHECK_EQ(http_code1, 401);
auto challenge = httpserver_test::extract_digest_challenge(headers1);
LT_ASSERT_EQ(challenge.has_value(), true);
// Derive HA1 from a password the server doesn't know about.
unsigned char wrong_ha1[16];
httpserver_test::detail::md5("myuser:examplerealm:totallywrong", wrong_ha1);
std::string cnonce = httpserver_test::make_cnonce();
std::string response = httpserver_test::compute_response_ha1(
*challenge, httpserver_test::digest_hash::md5,
"GET", "/base", wrong_ha1, 16, cnonce, "00000001");
std::string auth_header = "Authorization: " +
httpserver_test::build_authorization_header(
*challenge, "myuser", "/base", cnonce, "00000001", response);
std::string body2;
const std::string url2 = "localhost:" + std::to_string(port) + "/base";
long http_code2 = perform_with_auth_header( // NOLINT(runtime/int)
url2.c_str(), auth_header.c_str(), &body2);
LT_CHECK_EQ(http_code2, 401);
LT_CHECK_EQ(body2, std::string("FAIL"));
ws.stop();
LT_END_AUTO_TEST(digest_auth_with_ha1_md5_wrong_pass)
// HA1-precomputed SHA-256 path. Same shape as the MD5 sibling
// (test signs with the configured 32-byte HA1 directly, never sends
// cleartext). Additionally asserts the algorithm token on the wire is
// "SHA-256" -- the canonical RFC 7616 §3.3 token, distinct from "MD5".
LT_BEGIN_AUTO_TEST(authentication_suite, digest_auth_with_ha1_sha256)
webserver ws{create_webserver(0)
.digest_auth_random("myrandom")
.nonce_nc_size(300)};
auto digest_ha1 = std::make_shared<digest_ha1_resource>(
digest_ha1_resource::http_utils::digest_algorithm::SHA256,
PRECOMPUTED_HA1_SHA256, httpserver::http::http_utils::sha256_digest_size);
ws.register_path("base", digest_ha1);
ws.start(false);
const uint16_t port = ws.get_bound_port();
#if defined(_WINDOWS)
curl_global_init(CURL_GLOBAL_WIN32);
#else
curl_global_init(CURL_GLOBAL_ALL);
#endif
// Round 1.
const std::string url1 = "localhost:" + std::to_string(port) + "/base";
auto [http_code1, headers1] = collect_challenge(url1.c_str());
LT_CHECK_EQ(http_code1, 401);
auto challenge = httpserver_test::extract_digest_challenge(headers1);
LT_ASSERT_EQ(challenge.has_value(), true);
LT_CHECK_EQ(challenge->algorithm, std::string("SHA-256"));
std::string cnonce = httpserver_test::make_cnonce();
std::string response = httpserver_test::compute_response_ha1(
*challenge, httpserver_test::digest_hash::sha256,
"GET", "/base", PRECOMPUTED_HA1_SHA256, 32, cnonce, "00000001");
std::string auth_header = "Authorization: " +
httpserver_test::build_authorization_header(
*challenge, "myuser", "/base", cnonce, "00000001", response);
std::string body2;
const std::string url2 = "localhost:" + std::to_string(port) + "/base";
long http_code2 = perform_with_auth_header( // NOLINT(runtime/int)
url2.c_str(), auth_header.c_str(), &body2);
LT_CHECK_EQ(http_code2, 200);
LT_CHECK_EQ(body2, std::string("SUCCESS"));
ws.stop();
LT_END_AUTO_TEST(digest_auth_with_ha1_sha256)
// HA1-precomputed SHA-256 negative variant. Signs with a
// wrong-password-derived SHA-256 HA1; server rejects with 401.
LT_BEGIN_AUTO_TEST(authentication_suite, digest_auth_with_ha1_sha256_wrong_pass)
webserver ws{create_webserver(0)
.digest_auth_random("myrandom")
.nonce_nc_size(300)};
auto digest_ha1 = std::make_shared<digest_ha1_resource>(
digest_ha1_resource::http_utils::digest_algorithm::SHA256,
PRECOMPUTED_HA1_SHA256, httpserver::http::http_utils::sha256_digest_size);
ws.register_path("base", digest_ha1);
ws.start(false);
const uint16_t port = ws.get_bound_port();
#if defined(_WINDOWS)
curl_global_init(CURL_GLOBAL_WIN32);
#else
curl_global_init(CURL_GLOBAL_ALL);
#endif
// Round 1.
const std::string url1 = "localhost:" + std::to_string(port) + "/base";
auto [http_code1, headers1] = collect_challenge(url1.c_str());
LT_CHECK_EQ(http_code1, 401);
auto challenge = httpserver_test::extract_digest_challenge(headers1);
LT_ASSERT_EQ(challenge.has_value(), true);
unsigned char wrong_ha1[32];
httpserver_test::detail::sha256("myuser:examplerealm:totallywrong", wrong_ha1);
std::string cnonce = httpserver_test::make_cnonce();
std::string response = httpserver_test::compute_response_ha1(
*challenge, httpserver_test::digest_hash::sha256,
"GET", "/base", wrong_ha1, 32, cnonce, "00000001");
std::string auth_header = "Authorization: " +
httpserver_test::build_authorization_header(
*challenge, "myuser", "/base", cnonce, "00000001", response);
std::string body2;
const std::string url2 = "localhost:" + std::to_string(port) + "/base";
long http_code2 = perform_with_auth_header( // NOLINT(runtime/int)
url2.c_str(), auth_header.c_str(), &body2);
LT_CHECK_EQ(http_code2, 401);
LT_CHECK_EQ(body2, std::string("FAIL"));
ws.stop();
LT_END_AUTO_TEST(digest_auth_with_ha1_sha256_wrong_pass)
// Resource that tests get_digested_user() caching.
// Covers http_request.cpp lines 293-295 (cache hit) and 300 (nullptr branch).
//
// Migrated from the legacy `unauthorized("Digest", "testrealm",
// "FAIL")` static-string overload to the RFC 7616 `digest_challenge{...}`
// factory so the nonce/opaque handshake can complete. Once the client has
// authenticated, the resource validates via check_digest_auth() and then
// exercises both get_digested_user() entries (population + cache-hit).
class digest_user_cache_resource : public http_resource {
public:
http_response render_get(const http_request& req) {
using httpserver::http::http_utils;
using httpserver::digest_challenge;
// First call: populates the cache from MHD (or returns empty if no
// Authorization header has been sent yet).
std::string user1 = std::string(req.get_digested_user());
if (user1.empty()) {
// No digest auth provided -- emit the initial RFC 7616 §3.3
// challenge so a client can compute a response.
return http_response::unauthorized(
digest_challenge{.realm = "testrealm",
.response_body = "FAIL"});
}
// Validate the round-2 credentials. NOT_AUTHORIZED here means the
// response token didn't verify against the configured password.
auto result = req.check_digest_auth("testrealm", "testpass", 300,
0, http_utils::digest_algorithm::MD5);
if (result != http_utils::digest_auth_result::OK) {
return http_response::unauthorized(
digest_challenge{.realm = "testrealm",
.response_body = "FAIL"});
}
// Second call: must hit the cache (lines 293-295 in http_request.cpp).
std::string user2 = std::string(req.get_digested_user());
if (user1 != user2) {
return http_response::string("CACHE_MISMATCH").with_status(500);
}
return http_response::string("USER:" + user1);
}
};
// Test digested user caching when no digest auth is provided (nullptr branch)
LT_BEGIN_AUTO_TEST(authentication_suite, digest_user_cache_no_auth)
webserver ws{create_webserver(0)
.digest_auth_random("myrandom")
.nonce_nc_size(300)};
auto resource = std::make_shared<digest_user_cache_resource>();
ws.register_path("cache_test", resource);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
std::string s;
CURL *curl = curl_easy_init();
CURLcode res;
long http_code = 0; // NOLINT(runtime/int)
// No authentication - should trigger 401 challenge
const std::string url = "localhost:" + std::to_string(port) + "/cache_test";
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
LT_ASSERT_EQ(res, 0);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
LT_CHECK_EQ(http_code, 401);
curl_easy_cleanup(curl);
ws.stop();
LT_END_AUTO_TEST(digest_user_cache_no_auth)
// The resource emits a full RFC 7616 challenge, so the test can
// drive the handshake to completion. Round 2 carries a hand-built
// Authorization header; on the round-2 server-side validation pass,
// digest_user_cache_resource exercises BOTH legs of get_digested_user()
// caching (the populate + the cache-hit), returning "USER:testuser".
LT_BEGIN_AUTO_TEST(authentication_suite, digest_user_cache_with_auth)
webserver ws{create_webserver(0)
.digest_auth_random("myrandom")
.nonce_nc_size(300)};
auto resource = std::make_shared<digest_user_cache_resource>();
ws.register_path("cache_test", resource);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
// Round 1 -- collect the challenge.
const std::string url1 = "localhost:" + std::to_string(port) + "/cache_test";
auto [http_code1, headers1] = collect_challenge(url1.c_str());
LT_CHECK_EQ(http_code1, 401);
auto challenge = httpserver_test::extract_digest_challenge(headers1);
LT_ASSERT_EQ(challenge.has_value(), true);
// Round 2 -- sign with correct cleartext (testuser / testpass).
std::string cnonce = httpserver_test::make_cnonce();
std::string response = httpserver_test::compute_response_cleartext(
*challenge, httpserver_test::digest_hash::md5,
"GET", "/cache_test", "testuser", "testpass", cnonce, "00000001");
std::string auth_header = "Authorization: " +
httpserver_test::build_authorization_header(
*challenge, "testuser", "/cache_test", cnonce, "00000001", response);
std::string body2;
const std::string url2 = "localhost:" + std::to_string(port) + "/cache_test";
long http_code2 = perform_with_auth_header( // NOLINT(runtime/int)
url2.c_str(), auth_header.c_str(), &body2);
LT_CHECK_EQ(http_code2, 200);
LT_CHECK_EQ(body2, std::string("USER:testuser"));
ws.stop();
LT_END_AUTO_TEST(digest_user_cache_with_auth)
#endif
#ifdef HAVE_BAUTH
// Simple resource for centralized auth tests
class simple_resource : public http_resource {
public:
http_response render_get(const http_request&) {
return http_response::string("SUCCESS");
}
};
// Centralized authentication handler (migrated from
// std::shared_ptr<http_response> to std::optional<http_response>: nullopt
// allows the request; an engaged optional short-circuits with the value).
std::optional<http_response> centralized_auth_handler(const http_request& req) {
if (req.get_user() != "admin" || req.get_pass() != "secret") {
return http_response::unauthorized("Basic", "testrealm", "Unauthorized");
}
return std::nullopt; // Allow request
}
LT_BEGIN_AUTO_TEST(authentication_suite, centralized_auth_fail)
webserver ws{create_webserver(0)
.auth_handler(centralized_auth_handler)};
auto sr = std::make_shared<simple_resource>();
ws.register_path("protected", sr);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
std::string s;
CURL *curl = curl_easy_init();
CURLcode res;
long http_code = 0; // NOLINT(runtime/int)
const std::string url = "localhost:" + std::to_string(port) + "/protected";
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(http_code, 401);
LT_CHECK_EQ(s, "Unauthorized");
curl_easy_cleanup(curl);
ws.stop();
LT_END_AUTO_TEST(centralized_auth_fail)
LT_BEGIN_AUTO_TEST(authentication_suite, centralized_auth_success)
webserver ws{create_webserver(0)
.auth_handler(centralized_auth_handler)};
auto sr = std::make_shared<simple_resource>();
ws.register_path("protected", sr);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
std::string s;
CURL *curl = curl_easy_init();
CURLcode res;
long http_code = 0; // NOLINT(runtime/int)
curl_easy_setopt(curl, CURLOPT_USERNAME, "admin");
curl_easy_setopt(curl, CURLOPT_PASSWORD, "secret");
const std::string url = "localhost:" + std::to_string(port) + "/protected";
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(http_code, 200);
LT_CHECK_EQ(s, "SUCCESS");
curl_easy_cleanup(curl);
ws.stop();
LT_END_AUTO_TEST(centralized_auth_success)
LT_BEGIN_AUTO_TEST(authentication_suite, auth_skip_paths)
webserver ws{create_webserver(0)
.auth_handler(centralized_auth_handler)
.auth_skip_paths({"/health", "/public/*"})};
auto sr = std::make_shared<simple_resource>();
ws.register_path("health", sr);
ws.register_path("public/info", sr);
ws.register_path("protected", sr);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
CURL *curl;
CURLcode res;
long http_code = 0; // NOLINT(runtime/int)
std::string s;
// Test /health (exact match skip path) - should succeed without auth
curl = curl_easy_init();
s = "";
const std::string url_health = "localhost:" + std::to_string(port) + "/health";
curl_easy_setopt(curl, CURLOPT_URL, url_health.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(http_code, 200);
LT_CHECK_EQ(s, "SUCCESS");
curl_easy_cleanup(curl);
// Test /public/info (wildcard skip path) - should succeed without auth
curl = curl_easy_init();
s = "";
http_code = 0;
const std::string url_public = "localhost:" + std::to_string(port) + "/public/info";
curl_easy_setopt(curl, CURLOPT_URL, url_public.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(http_code, 200);
LT_CHECK_EQ(s, "SUCCESS");
curl_easy_cleanup(curl);
// Test /protected (not in skip paths) - should fail without auth
curl = curl_easy_init();
s = "";
http_code = 0;
const std::string url_protected = "localhost:" + std::to_string(port) + "/protected";
curl_easy_setopt(curl, CURLOPT_URL, url_protected.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(http_code, 401);
curl_easy_cleanup(curl);
ws.stop();
LT_END_AUTO_TEST(auth_skip_paths)
// Test that wildcard doesn't match partial prefix
// /publicinfo should NOT match /public/* (wildcard requires the slash)
LT_BEGIN_AUTO_TEST(authentication_suite, auth_skip_paths_no_partial_match)
webserver ws{create_webserver(0)
.auth_handler(centralized_auth_handler)
.auth_skip_paths({"/public/*"})};
auto sr = std::make_shared<simple_resource>();
ws.register_path("publicinfo", sr);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
std::string s;
CURL *curl = curl_easy_init();
CURLcode res;
long http_code = 0; // NOLINT(runtime/int)
// /publicinfo should NOT be skipped (doesn't match /public/*)
const std::string url = "localhost:" + std::to_string(port) + "/publicinfo";
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(http_code, 401); // Should require auth
curl_easy_cleanup(curl);
ws.stop();
LT_END_AUTO_TEST(auth_skip_paths_no_partial_match)
// Test deeply nested wildcard paths
LT_BEGIN_AUTO_TEST(authentication_suite, auth_skip_paths_deep_nested)
webserver ws{create_webserver(0)
.auth_handler(centralized_auth_handler)
.auth_skip_paths({"/api/v1/public/*"})};
auto sr = std::make_shared<simple_resource>();
ws.register_path("api/v1/public/users/list", sr);
ws.start(false);
const uint16_t port = ws.get_bound_port();
curl_global_init(CURL_GLOBAL_ALL);
std::string s;
CURL *curl = curl_easy_init();
CURLcode res;
long http_code = 0; // NOLINT(runtime/int)
// Deep nested path should be skipped
const std::string url = "localhost:" + std::to_string(port) + "/api/v1/public/users/list";
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
LT_ASSERT_EQ(res, 0);
LT_CHECK_EQ(http_code, 200);
LT_CHECK_EQ(s, "SUCCESS");
curl_easy_cleanup(curl);
ws.stop();
LT_END_AUTO_TEST(auth_skip_paths_deep_nested)
// SECURITY REGRESSION (path-normalization auth bypass): the auth-skip
// check and the route matcher must interpret the request path
// identically. Before the fix, should_skip_auth() collapsed ".."
// segments while the router did not, so "GET /admin/../public/x" looked
// like "/public/x" to the auth gate (auth skipped) yet still descended
// to the protected "/admin" prefix handler -- serving it unauthenticated.
// After the fix the dispatch path canonicalizes the URL once (dot-
// segments collapsed at answer_to_connection), so the same request is
// uniformly treated as "/public/x": auth is legitimately skipped and it
// routes to the public handler, never to /admin.
//
// CURLOPT_PATH_AS_IS is required -- libcurl collapses "/../" client-side
// by default, which would mask the server-side behaviour under test.
class admin_secret_resource : public http_resource {
public:
http_response render_get(const http_request&) {
return http_response::string("ADMIN_SECRET");
}
};
class public_ok_resource : public http_resource {
public:
http_response render_get(const http_request&) {
return http_response::string("PUBLIC_OK");
}
};