test_cookie_header.c (17151B)
1 /* 2 This file is part of GNUnet. 3 Copyright (C) 2026 Taler Systems SA 4 5 Paivana is free software; you can redistribute it and/or 6 modify it under the terms of the GNU Affero General Public License 7 as published by the Free Software Foundation; either version 8 3, or (at your option) any later version. 9 10 Paivana is distributed in the hope that it will be useful, 11 but WITHOUT ANY WARRANTY; without even the implied warranty 12 of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See 13 the GNU Affero General Public License for more details. 14 15 You should have received a copy of the GNU Affero General Public 16 License along with Paivana; see the file COPYING. If not, 17 write to the Free Software Foundation, Inc., 51 Franklin 18 Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 */ 20 21 /** 22 * @file test_cookie_header.c 23 * @brief tests the `Set-Cookie` line paivana emits for the access 24 * cookie: its `Path`, `Secure` and `Max-Age` attributes 25 * 26 * The cookie is the credential the client just paid for, and both 27 * attributes decide whether it ever comes back: 28 * 29 * - `Path` is matched by the browser against the *request* path, 30 * which is percent-encoded (RFC 6265 sections 5.1.4 and 5.4), while 31 * the URL paivana works with has been decoded by MHD. A `Path` that 32 * is emitted decoded never path-matches again, so the client pays 33 * and stays paywalled. 34 * - `Path` is also spliced into a header whose grammar (RFC 6265 35 * section 4.1.1) admits neither ';' nor CTLs, both of which a URI 36 * path may legitimately carry. 37 * - `Secure` decides whether the credential may travel in the clear. 38 * - `Max-Age` decides how long it lives, and a value of zero (RFC 6265 39 * section 5.2.2) deletes it on arrival. 40 * 41 * The integration suite cannot cover any of this: it runs paivana with 42 * -n, where the cookie path is never reached at all. 43 */ 44 #include "platform.h" 45 #include <gnunet/gnunet_util_lib.h> 46 #include <microhttpd.h> 47 #include "paivana-httpd_cookie.h" 48 49 /** 50 * Globals that paivana-httpd.c normally defines; the cookie 51 * compilation unit references them. 52 */ 53 int PH_global_cookie; 54 char *PH_base_url; 55 56 /** 57 * Number of checks that did not hold. 58 */ 59 static unsigned int failures; 60 61 62 /** 63 * Compute a `Set-Cookie` line granting access to @a website until 64 * @a expiration, from a fixed client address. 65 * 66 * @param website URL the cookie is minted for 67 * @param expiration end of the access being granted 68 * @return the header value, to be freed by the caller 69 */ 70 static char * 71 set_cookie_until (const char *website, 72 struct GNUNET_TIME_Timestamp expiration) 73 { 74 static const uint8_t ca[4] = { 203, 0, 113, 7 }; 75 76 return PAIVANA_HTTPD_compute_cookie (expiration, 77 website, 78 sizeof (ca), 79 ca); 80 } 81 82 83 /** 84 * Compute a `Set-Cookie` line for @a website with an expiration an 85 * hour out and a fixed client address. 86 * 87 * @param website URL the cookie is minted for 88 * @return the header value, to be freed by the caller 89 */ 90 static char * 91 set_cookie (const char *website) 92 { 93 return set_cookie_until ( 94 website, 95 GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS)); 96 } 97 98 99 /** 100 * Extract the value of attribute @a name from the `Set-Cookie` line 101 * @a sc. 102 * 103 * @param sc `Set-Cookie` value to search 104 * @param name attribute to look for, without the '=' 105 * @return allocated value, or NULL if the attribute is absent 106 */ 107 static char * 108 attribute (const char *sc, 109 const char *name) 110 { 111 const char *p = sc; 112 size_t nlen = strlen (name); 113 114 while (NULL != (p = strstr (p, 115 "; "))) 116 { 117 p += 2; 118 if (0 != strncasecmp (p, 119 name, 120 nlen)) 121 continue; 122 if ('=' != p[nlen]) 123 continue; 124 p += nlen + 1; 125 return GNUNET_strndup (p, 126 strcspn (p, 127 ";")); 128 } 129 return NULL; 130 } 131 132 133 /** 134 * Check that a cookie expiring at @a expiration carries a `Max-Age` 135 * between @a lo and @a hi inclusive. 136 * 137 * The bounds are a range because the lifetime is measured from the 138 * moment of the call while @a expiration is second-granular, so an 139 * hour out is 3599 or 3600 seconds depending on where in the current 140 * second we happen to be. 141 * 142 * @param label what the case is called in the log 143 * @param expiration end of the access being granted 144 * @param lo smallest acceptable `Max-Age` 145 * @param hi largest acceptable `Max-Age` 146 */ 147 static void 148 max_age_between (const char *label, 149 struct GNUNET_TIME_Timestamp expiration, 150 unsigned long long lo, 151 unsigned long long hi) 152 { 153 const char *website = "http://example.com/premium/article"; 154 char *sc; 155 char *got; 156 unsigned long long ma; 157 158 sc = set_cookie_until (website, 159 expiration); 160 got = attribute (sc, 161 "Max-Age"); 162 if ( (NULL == got) || 163 (1 != sscanf (got, 164 "%llu", 165 &ma)) ) 166 { 167 fprintf (stderr, 168 "FAIL: %s gives Max-Age=%s, want a number in [%llu,%llu]\n", 169 label, 170 (NULL != got) ? got : "(none)", 171 lo, 172 hi); 173 failures++; 174 } 175 else if ( (ma < lo) || 176 (ma > hi) ) 177 { 178 fprintf (stderr, 179 "FAIL: %s gives Max-Age=%llu, want [%llu,%llu]\n", 180 label, 181 ma, 182 lo, 183 hi); 184 failures++; 185 } 186 else 187 { 188 fprintf (stderr, 189 " ok: %s -> Max-Age=%llu\n", 190 label, 191 ma); 192 } 193 GNUNET_free (got); 194 GNUNET_free (sc); 195 } 196 197 198 /** 199 * Is the attribute @a name (one without a value, such as `Secure`) 200 * present in the `Set-Cookie` line @a sc? 201 * 202 * @param sc `Set-Cookie` value to search 203 * @param name attribute to look for 204 * @return true if present 205 */ 206 static bool 207 has_flag (const char *sc, 208 const char *name) 209 { 210 const char *p = sc; 211 size_t nlen = strlen (name); 212 213 while (NULL != (p = strstr (p, 214 "; "))) 215 { 216 p += 2; 217 if (0 != strncasecmp (p, 218 name, 219 nlen)) 220 continue; 221 if ( ('\0' == p[nlen]) || 222 (';' == p[nlen]) ) 223 return true; 224 } 225 return false; 226 } 227 228 229 /** 230 * Check that the cookie minted for @a website carries exactly the 231 * `Path` attribute @a want. 232 * 233 * @param website URL the cookie is minted for 234 * @param want expected `Path` value 235 */ 236 static void 237 path_is (const char *website, 238 const char *want) 239 { 240 char *sc; 241 char *got; 242 243 sc = set_cookie (website); 244 got = attribute (sc, 245 "Path"); 246 if ( (NULL == got) || 247 (0 != strcmp (got, 248 want)) ) 249 { 250 fprintf (stderr, 251 "FAIL: `%s' gives Path=%s, want Path=%s\n", 252 website, 253 (NULL != got) ? got : "(none)", 254 want); 255 failures++; 256 } 257 else 258 { 259 fprintf (stderr, 260 " ok: `%s' -> Path=%s\n", 261 website, 262 got); 263 } 264 GNUNET_free (got); 265 GNUNET_free (sc); 266 } 267 268 269 /** 270 * Check that the `Set-Cookie` line minted for @a website is one MHD 271 * will accept and that it contains no character the RFC 6265 272 * section 4.1.1 grammar forbids in an attribute value. 273 * 274 * MHD refuses CR and LF in a header value (response.c), which is what 275 * keeps this attribute injection rather than header injection; every 276 * other CTL it happily emits, so the check has to be ours. 277 * 278 * @param website URL the cookie is minted for 279 */ 280 static void 281 header_is_wellformed (const char *website) 282 { 283 char *sc; 284 struct MHD_Response *resp; 285 286 sc = set_cookie (website); 287 for (const char *p = sc; '\0' != *p; p++) 288 { 289 if ( (0x20 > (unsigned char) *p) || 290 (0x7F == (unsigned char) *p) ) 291 { 292 fprintf (stderr, 293 "FAIL: `%s' yields a Set-Cookie with a control character" 294 " at offset %u\n", 295 website, 296 (unsigned int) (p - sc)); 297 failures++; 298 GNUNET_free (sc); 299 return; 300 } 301 } 302 resp = MHD_create_response_from_buffer (0, 303 NULL, 304 MHD_RESPMEM_PERSISTENT); 305 GNUNET_assert (NULL != resp); 306 if (MHD_YES != 307 MHD_add_response_header (resp, 308 MHD_HTTP_HEADER_SET_COOKIE, 309 sc)) 310 { 311 fprintf (stderr, 312 "FAIL: MHD rejects the Set-Cookie line for `%s': %s\n", 313 website, 314 sc); 315 failures++; 316 } 317 else 318 { 319 fprintf (stderr, 320 " ok: `%s' -> %s\n", 321 website, 322 sc); 323 } 324 MHD_destroy_response (resp); 325 GNUNET_free (sc); 326 } 327 328 329 /** 330 * Check whether the cookie minted for @a website is marked `Secure`. 331 * 332 * @param website URL the cookie is minted for 333 * @param want true if `Secure` is expected 334 */ 335 static void 336 secure_is (const char *website, 337 bool want) 338 { 339 char *sc; 340 bool got; 341 342 sc = set_cookie (website); 343 got = has_flag (sc, 344 "Secure"); 345 if (got != want) 346 { 347 fprintf (stderr, 348 "FAIL: `%s' (BASE_URL %s) gives Secure=%s, want %s\n", 349 website, 350 (NULL != PH_base_url) ? PH_base_url : "(unset)", 351 got ? "true" : "false", 352 want ? "true" : "false"); 353 failures++; 354 } 355 else 356 { 357 fprintf (stderr, 358 " ok: `%s' (BASE_URL %s) Secure=%s\n", 359 website, 360 (NULL != PH_base_url) ? PH_base_url : "(unset)", 361 got ? "true" : "false"); 362 } 363 GNUNET_free (sc); 364 } 365 366 367 /** 368 * Check that the cookie minted for @a website is still accepted for 369 * that same website, i.e. that nothing done to the header broke the 370 * value itself. 371 * 372 * @param website URL the cookie is minted for 373 */ 374 static void 375 round_trips (const char *website) 376 { 377 static const uint8_t ca[4] = { 203, 0, 113, 7 }; 378 char *sc; 379 char *val; 380 char *semi; 381 382 sc = set_cookie (website); 383 val = strchr (sc, 384 '='); 385 GNUNET_assert (NULL != val); 386 val++; 387 semi = strchr (val, 388 ';'); 389 if (NULL != semi) 390 *semi = '\0'; 391 if (! PAIVANA_HTTPD_check_cookie (val, 392 website, 393 sizeof (ca), 394 ca)) 395 { 396 fprintf (stderr, 397 "FAIL: cookie minted for `%s' is not accepted for it\n", 398 website); 399 failures++; 400 } 401 else 402 { 403 fprintf (stderr, 404 " ok: cookie for `%s' verifies\n", 405 website); 406 } 407 GNUNET_free (sc); 408 } 409 410 411 int 412 main (int argc, 413 char *const *argv) 414 { 415 (void) argc; 416 (void) argv; 417 /* Quiet: the fallback cases log a warning by design. */ 418 GNUNET_assert (GNUNET_OK == 419 GNUNET_log_setup ("test-cookie-header", 420 "ERROR", 421 NULL)); 422 GNUNET_CRYPTO_hash ("test-cookie-header", 423 strlen ("test-cookie-header"), 424 &paivana_secret); 425 426 fprintf (stderr, 427 "-- Path is what the browser will send --\n"); 428 path_is ("http://example.com/premium/article", 429 "/premium/article"); 430 path_is ("http://example.com/", 431 "/"); 432 path_is ("http://example.com", 433 "/"); 434 /* MHD decodes the request URI before we see it, so a space arrives 435 as a space; the browser will ask for %20. */ 436 path_is ("http://example.com/premium/my article", 437 "/premium/my%20article"); 438 /* A client that echoes the encoded form back to the pay endpoint 439 must not have it encoded a second time. */ 440 path_is ("http://example.com/premium/my%20article", 441 "/premium/my%20article"); 442 /* Non-ASCII: UTF-8 octets, one triplet each (RFC 3986 section 2.5). */ 443 path_is ("http://example.com/artikel/gr\xc3\xbc\xc3\x9f" "e", 444 "/artikel/gr%C3%BC%C3%9Fe"); 445 /* A '%' that is not an escape is itself escaped. */ 446 path_is ("http://example.com/a%zz", 447 "/a%25zz"); 448 path_is ("http://example.com/100%", 449 "/100%25"); 450 /* Characters a browser leaves alone stay literal (RFC 3986 451 section 3.3 pchar). */ 452 path_is ("http://example.com/a-b_c.d~e/f:g@h/i,j=k&l+m!n$o'p(q)r*s", 453 "/a-b_c.d~e/f:g@h/i,j=k&l+m!n$o'p(q)r*s"); 454 /* RFC 3986 section 3.3: the path ends at '?' or '#'; carrying the 455 query into Path= would match nothing at all. */ 456 path_is ("http://example.com/search?q=1", 457 "/search"); 458 path_is ("http://example.com/page#top", 459 "/page"); 460 /* Ports and userinfo are not part of the path. */ 461 path_is ("https://example.com:8443/paid/x", 462 "/paid/x"); 463 464 fprintf (stderr, 465 "-- paths RFC 6265 section 4.1.1 cannot express --\n"); 466 /* ';' is a sub-delim in a path but terminates the attribute here, 467 and %3B would no longer path-match: fall back to '/'. */ 468 path_is ("http://example.com/a;Domain=evil.example.com", 469 "/"); 470 path_is ("http://example.com/a;b", 471 "/"); 472 /* A CTL that arrived decoded. Encoding it is enough to satisfy the 473 grammar, and %0A is what the browser sends, so it still matches. */ 474 path_is ("http://example.com/a\nb", 475 "/a%0Ab"); 476 path_is ("http://example.com/a\x7f" "b", 477 "/a%7Fb"); 478 /* ...but one that came in encoded is passed through as it stands. */ 479 path_is ("http://example.com/a%0Ab", 480 "/a%0Ab"); 481 482 fprintf (stderr, 483 "-- the resulting header is well-formed --\n"); 484 header_is_wellformed ("http://example.com/premium/article"); 485 header_is_wellformed ("http://example.com/premium/my article"); 486 header_is_wellformed ("http://example.com/a;Domain=evil.example.com"); 487 header_is_wellformed ("http://example.com/a\nb"); 488 header_is_wellformed ("http://example.com/a\r\nSet-Cookie: x=y"); 489 header_is_wellformed ("http://example.com/artikel/gr\xc3\xbc\xc3\x9f" "e"); 490 491 fprintf (stderr, 492 "-- attribute injection through the path --\n"); 493 { 494 char *sc; 495 char *dom; 496 497 sc = set_cookie ("http://example.com/a;Domain=evil.example.com"); 498 dom = attribute (sc, 499 "Domain"); 500 if (NULL != dom) 501 { 502 fprintf (stderr, 503 "FAIL: a path injected Domain=%s into the Set-Cookie line\n", 504 dom); 505 failures++; 506 } 507 else 508 { 509 fprintf (stderr, 510 " ok: no Domain attribute smuggled in\n"); 511 } 512 GNUNET_free (dom); 513 GNUNET_free (sc); 514 } 515 516 fprintf (stderr, 517 "-- the global cookie is scoped to the whole site --\n"); 518 PH_global_cookie = 1; 519 path_is ("http://example.com/premium/my article", 520 "/"); 521 path_is ("http://example.com/a;b", 522 "/"); 523 PH_global_cookie = 0; 524 525 fprintf (stderr, 526 "-- Secure --\n"); 527 /* Without BASE_URL there is nothing but the URL to go on. */ 528 secure_is ("https://example.com/paid", 529 true); 530 secure_is ("http://example.com/paid", 531 false); 532 secure_is ("HTTPS://example.com/paid", 533 true); 534 /* With BASE_URL the operator has stated the scheme clients reach us 535 with, and a website URL chosen elsewhere does not get to override 536 it -- in either direction. */ 537 PH_base_url = (char *) "https://example.com"; 538 secure_is ("http://example.com/paid", 539 true); 540 secure_is ("https://example.com/paid", 541 true); 542 PH_base_url = (char *) "http://example.com"; 543 secure_is ("https://example.com/paid", 544 false); 545 secure_is ("http://example.com/paid", 546 false); 547 PH_base_url = NULL; 548 549 fprintf (stderr, 550 "-- Max-Age --\n"); 551 /* An expiration is second-granular, so one observed from the middle 552 of the preceding second leaves under a second of lifetime. RFC 553 6265 section 5.2.2 reads a Max-Age of zero as "expire this cookie 554 immediately", which would delete the access the client just paid 555 for on arrival; the smallest lifetime the attribute can express is 556 one second. */ 557 max_age_between ("the next whole second", 558 GNUNET_TIME_absolute_to_timestamp ( 559 GNUNET_TIME_relative_to_absolute ( 560 GNUNET_TIME_UNIT_SECONDS)), 561 1, 562 1); 563 max_age_between ("an hour out", 564 GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS), 565 3599, 566 3600); 567 /* 'forever' is the default max_pickup_delay, so it is the ordinary 568 case rather than an edge one. */ 569 max_age_between ("forever", 570 GNUNET_TIME_UNIT_FOREVER_TS, 571 365ULL * 24 * 60 * 60, 572 ULLONG_MAX); 573 574 fprintf (stderr, 575 "-- the cookie value still verifies --\n"); 576 round_trips ("http://example.com/premium/my article"); 577 round_trips ("http://example.com/a;b"); 578 round_trips ("https://example.com/paid"); 579 580 if (0 != failures) 581 { 582 fprintf (stderr, 583 "%u check(s) failed\n", 584 failures); 585 return 1; 586 } 587 fprintf (stderr, 588 "all checks passed\n"); 589 return 0; 590 }