paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

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 }