/*
     This file is part of GNUnet.
     Copyright (C) 2026 Taler Systems SA

     Paivana is free software; you can redistribute it and/or
     modify it under the terms of the GNU Affero General Public License
     as published by the Free Software Foundation; either version
     3, or (at your option) any later version.

     Paivana 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 Affero General Public License for more details.

     You should have received a copy of the GNU Affero General Public
     License along with Paivana; see the file COPYING.  If not,
     write to the Free Software Foundation, Inc., 51 Franklin
     Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

/**
 * @file test_cookie_header.c
 * @brief tests the `Set-Cookie` line paivana emits for the access
 *        cookie: its `Path`, `Secure` and `Max-Age` attributes
 *
 * The cookie is the credential the client just paid for, and both
 * attributes decide whether it ever comes back:
 *
 * - `Path` is matched by the browser against the *request* path,
 *   which is percent-encoded (RFC 6265 sections 5.1.4 and 5.4), while
 *   the URL paivana works with has been decoded by MHD.  A `Path` that
 *   is emitted decoded never path-matches again, so the client pays
 *   and stays paywalled.
 * - `Path` is also spliced into a header whose grammar (RFC 6265
 *   section 4.1.1) admits neither ';' nor CTLs, both of which a URI
 *   path may legitimately carry.
 * - `Secure` decides whether the credential may travel in the clear.
 * - `Max-Age` decides how long it lives, and a value of zero (RFC 6265
 *   section 5.2.2) deletes it on arrival.
 *
 * The integration suite cannot cover any of this: it runs paivana with
 * -n, where the cookie path is never reached at all.
 */
#include "platform.h"
#include <gnunet/gnunet_util_lib.h>
#include <microhttpd.h>
#include "paivana-httpd_cookie.h"

/**
 * Globals that paivana-httpd.c normally defines; the cookie
 * compilation unit references them.
 */
int PH_global_cookie;
char *PH_base_url;

/**
 * Number of checks that did not hold.
 */
static unsigned int failures;


/**
 * Compute a `Set-Cookie` line granting access to @a website until
 * @a expiration, from a fixed client address.
 *
 * @param website URL the cookie is minted for
 * @param expiration end of the access being granted
 * @return the header value, to be freed by the caller
 */
static char *
set_cookie_until (const char *website,
                  struct GNUNET_TIME_Timestamp expiration)
{
  static const uint8_t ca[4] = { 203, 0, 113, 7 };

  return PAIVANA_HTTPD_compute_cookie (expiration,
                                       website,
                                       sizeof (ca),
                                       ca);
}


/**
 * Compute a `Set-Cookie` line for @a website with an expiration an
 * hour out and a fixed client address.
 *
 * @param website URL the cookie is minted for
 * @return the header value, to be freed by the caller
 */
static char *
set_cookie (const char *website)
{
  return set_cookie_until (
    website,
    GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS));
}


/**
 * Extract the value of attribute @a name from the `Set-Cookie` line
 * @a sc.
 *
 * @param sc `Set-Cookie` value to search
 * @param name attribute to look for, without the '='
 * @return allocated value, or NULL if the attribute is absent
 */
static char *
attribute (const char *sc,
           const char *name)
{
  const char *p = sc;
  size_t nlen = strlen (name);

  while (NULL != (p = strstr (p,
                              "; ")))
  {
    p += 2;
    if (0 != strncasecmp (p,
                          name,
                          nlen))
      continue;
    if ('=' != p[nlen])
      continue;
    p += nlen + 1;
    return GNUNET_strndup (p,
                           strcspn (p,
                                    ";"));
  }
  return NULL;
}


/**
 * Check that a cookie expiring at @a expiration carries a `Max-Age`
 * between @a lo and @a hi inclusive.
 *
 * The bounds are a range because the lifetime is measured from the
 * moment of the call while @a expiration is second-granular, so an
 * hour out is 3599 or 3600 seconds depending on where in the current
 * second we happen to be.
 *
 * @param label what the case is called in the log
 * @param expiration end of the access being granted
 * @param lo smallest acceptable `Max-Age`
 * @param hi largest acceptable `Max-Age`
 */
static void
max_age_between (const char *label,
                 struct GNUNET_TIME_Timestamp expiration,
                 unsigned long long lo,
                 unsigned long long hi)
{
  const char *website = "http://example.com/premium/article";
  char *sc;
  char *got;
  unsigned long long ma;

  sc = set_cookie_until (website,
                         expiration);
  got = attribute (sc,
                   "Max-Age");
  if ( (NULL == got) ||
       (1 != sscanf (got,
                     "%llu",
                     &ma)) )
  {
    fprintf (stderr,
             "FAIL: %s gives Max-Age=%s, want a number in [%llu,%llu]\n",
             label,
             (NULL != got) ? got : "(none)",
             lo,
             hi);
    failures++;
  }
  else if ( (ma < lo) ||
            (ma > hi) )
  {
    fprintf (stderr,
             "FAIL: %s gives Max-Age=%llu, want [%llu,%llu]\n",
             label,
             ma,
             lo,
             hi);
    failures++;
  }
  else
  {
    fprintf (stderr,
             "  ok: %s -> Max-Age=%llu\n",
             label,
             ma);
  }
  GNUNET_free (got);
  GNUNET_free (sc);
}


/**
 * Is the attribute @a name (one without a value, such as `Secure`)
 * present in the `Set-Cookie` line @a sc?
 *
 * @param sc `Set-Cookie` value to search
 * @param name attribute to look for
 * @return true if present
 */
static bool
has_flag (const char *sc,
          const char *name)
{
  const char *p = sc;
  size_t nlen = strlen (name);

  while (NULL != (p = strstr (p,
                              "; ")))
  {
    p += 2;
    if (0 != strncasecmp (p,
                          name,
                          nlen))
      continue;
    if ( ('\0' == p[nlen]) ||
         (';' == p[nlen]) )
      return true;
  }
  return false;
}


/**
 * Check that the cookie minted for @a website carries exactly the
 * `Path` attribute @a want.
 *
 * @param website URL the cookie is minted for
 * @param want expected `Path` value
 */
static void
path_is (const char *website,
         const char *want)
{
  char *sc;
  char *got;

  sc = set_cookie (website);
  got = attribute (sc,
                   "Path");
  if ( (NULL == got) ||
       (0 != strcmp (got,
                     want)) )
  {
    fprintf (stderr,
             "FAIL: `%s' gives Path=%s, want Path=%s\n",
             website,
             (NULL != got) ? got : "(none)",
             want);
    failures++;
  }
  else
  {
    fprintf (stderr,
             "  ok: `%s' -> Path=%s\n",
             website,
             got);
  }
  GNUNET_free (got);
  GNUNET_free (sc);
}


/**
 * Check that the `Set-Cookie` line minted for @a website is one MHD
 * will accept and that it contains no character the RFC 6265
 * section 4.1.1 grammar forbids in an attribute value.
 *
 * MHD refuses CR and LF in a header value (response.c), which is what
 * keeps this attribute injection rather than header injection; every
 * other CTL it happily emits, so the check has to be ours.
 *
 * @param website URL the cookie is minted for
 */
static void
header_is_wellformed (const char *website)
{
  char *sc;
  struct MHD_Response *resp;

  sc = set_cookie (website);
  for (const char *p = sc; '\0' != *p; p++)
  {
    if ( (0x20 > (unsigned char) *p) ||
         (0x7F == (unsigned char) *p) )
    {
      fprintf (stderr,
               "FAIL: `%s' yields a Set-Cookie with a control character"
               " at offset %u\n",
               website,
               (unsigned int) (p - sc));
      failures++;
      GNUNET_free (sc);
      return;
    }
  }
  resp = MHD_create_response_from_buffer (0,
                                          NULL,
                                          MHD_RESPMEM_PERSISTENT);
  GNUNET_assert (NULL != resp);
  if (MHD_YES !=
      MHD_add_response_header (resp,
                               MHD_HTTP_HEADER_SET_COOKIE,
                               sc))
  {
    fprintf (stderr,
             "FAIL: MHD rejects the Set-Cookie line for `%s': %s\n",
             website,
             sc);
    failures++;
  }
  else
  {
    fprintf (stderr,
             "  ok: `%s' -> %s\n",
             website,
             sc);
  }
  MHD_destroy_response (resp);
  GNUNET_free (sc);
}


/**
 * Check whether the cookie minted for @a website is marked `Secure`.
 *
 * @param website URL the cookie is minted for
 * @param want true if `Secure` is expected
 */
static void
secure_is (const char *website,
           bool want)
{
  char *sc;
  bool got;

  sc = set_cookie (website);
  got = has_flag (sc,
                  "Secure");
  if (got != want)
  {
    fprintf (stderr,
             "FAIL: `%s' (BASE_URL %s) gives Secure=%s, want %s\n",
             website,
             (NULL != PH_base_url) ? PH_base_url : "(unset)",
             got ? "true" : "false",
             want ? "true" : "false");
    failures++;
  }
  else
  {
    fprintf (stderr,
             "  ok: `%s' (BASE_URL %s) Secure=%s\n",
             website,
             (NULL != PH_base_url) ? PH_base_url : "(unset)",
             got ? "true" : "false");
  }
  GNUNET_free (sc);
}


/**
 * Check that the cookie minted for @a website is still accepted for
 * that same website, i.e. that nothing done to the header broke the
 * value itself.
 *
 * @param website URL the cookie is minted for
 */
static void
round_trips (const char *website)
{
  static const uint8_t ca[4] = { 203, 0, 113, 7 };
  char *sc;
  char *val;
  char *semi;

  sc = set_cookie (website);
  val = strchr (sc,
                '=');
  GNUNET_assert (NULL != val);
  val++;
  semi = strchr (val,
                 ';');
  if (NULL != semi)
    *semi = '\0';
  if (! PAIVANA_HTTPD_check_cookie (val,
                                    website,
                                    sizeof (ca),
                                    ca))
  {
    fprintf (stderr,
             "FAIL: cookie minted for `%s' is not accepted for it\n",
             website);
    failures++;
  }
  else
  {
    fprintf (stderr,
             "  ok: cookie for `%s' verifies\n",
             website);
  }
  GNUNET_free (sc);
}


int
main (int argc,
      char *const *argv)
{
  (void) argc;
  (void) argv;
  /* Quiet: the fallback cases log a warning by design. */
  GNUNET_assert (GNUNET_OK ==
                 GNUNET_log_setup ("test-cookie-header",
                                   "ERROR",
                                   NULL));
  GNUNET_CRYPTO_hash ("test-cookie-header",
                      strlen ("test-cookie-header"),
                      &paivana_secret);

  fprintf (stderr,
           "-- Path is what the browser will send --\n");
  path_is ("http://example.com/premium/article",
           "/premium/article");
  path_is ("http://example.com/",
           "/");
  path_is ("http://example.com",
           "/");
  /* MHD decodes the request URI before we see it, so a space arrives
     as a space; the browser will ask for %20. */
  path_is ("http://example.com/premium/my article",
           "/premium/my%20article");
  /* A client that echoes the encoded form back to the pay endpoint
     must not have it encoded a second time. */
  path_is ("http://example.com/premium/my%20article",
           "/premium/my%20article");
  /* Non-ASCII: UTF-8 octets, one triplet each (RFC 3986 section 2.5). */
  path_is ("http://example.com/artikel/gr\xc3\xbc\xc3\x9f" "e",
           "/artikel/gr%C3%BC%C3%9Fe");
  /* A '%' that is not an escape is itself escaped. */
  path_is ("http://example.com/a%zz",
           "/a%25zz");
  path_is ("http://example.com/100%",
           "/100%25");
  /* Characters a browser leaves alone stay literal (RFC 3986
     section 3.3 pchar). */
  path_is ("http://example.com/a-b_c.d~e/f:g@h/i,j=k&l+m!n$o'p(q)r*s",
           "/a-b_c.d~e/f:g@h/i,j=k&l+m!n$o'p(q)r*s");
  /* RFC 3986 section 3.3: the path ends at '?' or '#'; carrying the
     query into Path= would match nothing at all. */
  path_is ("http://example.com/search?q=1",
           "/search");
  path_is ("http://example.com/page#top",
           "/page");
  /* Ports and userinfo are not part of the path. */
  path_is ("https://example.com:8443/paid/x",
           "/paid/x");

  fprintf (stderr,
           "-- paths RFC 6265 section 4.1.1 cannot express --\n");
  /* ';' is a sub-delim in a path but terminates the attribute here,
     and %3B would no longer path-match: fall back to '/'. */
  path_is ("http://example.com/a;Domain=evil.example.com",
           "/");
  path_is ("http://example.com/a;b",
           "/");
  /* A CTL that arrived decoded.  Encoding it is enough to satisfy the
     grammar, and %0A is what the browser sends, so it still matches. */
  path_is ("http://example.com/a\nb",
           "/a%0Ab");
  path_is ("http://example.com/a\x7f" "b",
           "/a%7Fb");
  /* ...but one that came in encoded is passed through as it stands. */
  path_is ("http://example.com/a%0Ab",
           "/a%0Ab");

  fprintf (stderr,
           "-- the resulting header is well-formed --\n");
  header_is_wellformed ("http://example.com/premium/article");
  header_is_wellformed ("http://example.com/premium/my article");
  header_is_wellformed ("http://example.com/a;Domain=evil.example.com");
  header_is_wellformed ("http://example.com/a\nb");
  header_is_wellformed ("http://example.com/a\r\nSet-Cookie: x=y");
  header_is_wellformed ("http://example.com/artikel/gr\xc3\xbc\xc3\x9f" "e");

  fprintf (stderr,
           "-- attribute injection through the path --\n");
  {
    char *sc;
    char *dom;

    sc = set_cookie ("http://example.com/a;Domain=evil.example.com");
    dom = attribute (sc,
                     "Domain");
    if (NULL != dom)
    {
      fprintf (stderr,
               "FAIL: a path injected Domain=%s into the Set-Cookie line\n",
               dom);
      failures++;
    }
    else
    {
      fprintf (stderr,
               "  ok: no Domain attribute smuggled in\n");
    }
    GNUNET_free (dom);
    GNUNET_free (sc);
  }

  fprintf (stderr,
           "-- the global cookie is scoped to the whole site --\n");
  PH_global_cookie = 1;
  path_is ("http://example.com/premium/my article",
           "/");
  path_is ("http://example.com/a;b",
           "/");
  PH_global_cookie = 0;

  fprintf (stderr,
           "-- Secure --\n");
  /* Without BASE_URL there is nothing but the URL to go on. */
  secure_is ("https://example.com/paid",
             true);
  secure_is ("http://example.com/paid",
             false);
  secure_is ("HTTPS://example.com/paid",
             true);
  /* With BASE_URL the operator has stated the scheme clients reach us
     with, and a website URL chosen elsewhere does not get to override
     it -- in either direction. */
  PH_base_url = (char *) "https://example.com";
  secure_is ("http://example.com/paid",
             true);
  secure_is ("https://example.com/paid",
             true);
  PH_base_url = (char *) "http://example.com";
  secure_is ("https://example.com/paid",
             false);
  secure_is ("http://example.com/paid",
             false);
  PH_base_url = NULL;

  fprintf (stderr,
           "-- Max-Age --\n");
  /* An expiration is second-granular, so one observed from the middle
     of the preceding second leaves under a second of lifetime.  RFC
     6265 section 5.2.2 reads a Max-Age of zero as "expire this cookie
     immediately", which would delete the access the client just paid
     for on arrival; the smallest lifetime the attribute can express is
     one second. */
  max_age_between ("the next whole second",
                   GNUNET_TIME_absolute_to_timestamp (
                     GNUNET_TIME_relative_to_absolute (
                       GNUNET_TIME_UNIT_SECONDS)),
                   1,
                   1);
  max_age_between ("an hour out",
                   GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
                   3599,
                   3600);
  /* 'forever' is the default max_pickup_delay, so it is the ordinary
     case rather than an edge one. */
  max_age_between ("forever",
                   GNUNET_TIME_UNIT_FOREVER_TS,
                   365ULL * 24 * 60 * 60,
                   ULLONG_MAX);

  fprintf (stderr,
           "-- the cookie value still verifies --\n");
  round_trips ("http://example.com/premium/my article");
  round_trips ("http://example.com/a;b");
  round_trips ("https://example.com/paid");

  if (0 != failures)
  {
    fprintf (stderr,
             "%u check(s) failed\n",
             failures);
    return 1;
  }
  fprintf (stderr,
           "all checks passed\n");
  return 0;
}
