commit 66635b91012b8430e9b06da33446b7b63478a7c2
parent e51a4872e23736cf7701806ce17700005a21ab92
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 6 Aug 2026 19:13:57 +0200
construct proper cookie path
Diffstat:
4 files changed, 665 insertions(+), 8 deletions(-)
diff --git a/src/backend/paivana-httpd_cookie.c b/src/backend/paivana-httpd_cookie.c
@@ -143,6 +143,121 @@ PAIVANA_HTTPD_check_cookie (const char *cookie,
}
+/**
+ * Is @a c a character a cookie `Path` attribute may carry literally?
+ *
+ * The value has to satisfy two grammars at once. RFC 3986 section 3.3
+ * gives what a URI path may spell out:
+ * pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
+ * and RFC 6265 section 4.1.1 gives what the attribute may contain:
+ * path-value = <any CHAR except CTLs or ";">
+ * so the literal set is pchar without ';', plus the '/' that separates
+ * segments. Everything else is what a browser percent-encodes in the
+ * request-URI, and hence what RFC 6265 section 5.1.4 path-match will
+ * compare against.
+ *
+ * @param c character to classify
+ * @return true if @a c may be emitted as-is
+ */
+static bool
+path_char_literal_ok (unsigned char c)
+{
+ if ( ( ('a' <= c) && ('z' >= c) ) ||
+ ( ('A' <= c) && ('Z' >= c) ) ||
+ ( ('0' <= c) && ('9' >= c) ) )
+ return true; /* unreserved, RFC 3986 sec 2.3 */
+ if ('\0' == c)
+ return false;
+ return NULL != strchr ("-._~" /* unreserved, RFC 3986 sec 2.3 */
+ "!$&'()*+,=" /* sub-delims minus ';', sec 2.2 */
+ ":@" /* the rest of pchar, sec 3.3 */
+ "/", /* segment separator, sec 3.3 */
+ c);
+}
+
+
+/**
+ * Render @a path as an RFC 6265 section 4.1.1 `path-value` that
+ * path-matches the requests a browser will actually make for it.
+ *
+ * The path we are handed has been through MHD, which percent-decodes
+ * the request URI before the handler sees it, and then through the
+ * client, which echoes it back to the payment endpoint. The browser,
+ * however, matches the stored cookie-path against the *request* path
+ * (RFC 6265 sections 5.1.4 and 5.4), which is the encoded one -- so a
+ * decoded path is emitted only to never match again. Re-encode
+ * everything outside the literal set, and pass an existing
+ * percent-triplet through unchanged so that an already-encoded path
+ * does not get encoded twice. A literal '%' that happens to be
+ * followed by two hex digits is indistinguishable from a triplet here;
+ * that ambiguity is inherent to having been decoded once already.
+ *
+ * @param path path component of the website URL, starting at its '/'
+ * @return the attribute value, or NULL if @a path cannot be expressed
+ * as one; the caller then has to fall back to "/"
+ */
+static char *
+encode_path (const char *path)
+{
+ static const char hex[] = "0123456789ABCDEF";
+ size_t len;
+ size_t off = 0;
+ char *res;
+
+ if ('/' != path[0])
+ {
+ /* RFC 6265 section 5.2.4: a Path that does not begin with '/' is
+ discarded by the user agent in favour of the default-path. */
+ GNUNET_break (0);
+ return NULL;
+ }
+ /* RFC 3986 section 3.3: the path ends at the first '?' or '#'; a
+ query or a fragment is not part of it. The request-path of
+ RFC 6265 section 5.1.4 is likewise taken "without the %x3F ('?')
+ character or query string", so leaving one in yields a cookie-path
+ nothing can ever path-match. */
+ len = strcspn (path,
+ "?#");
+ res = GNUNET_malloc (3 * len + 1);
+ for (size_t i = 0; i<len; i++)
+ {
+ unsigned char c = (unsigned char) path[i];
+
+ if (';' == c)
+ {
+ /* RFC 6265 section 4.1.1 excludes ';' from path-value outright
+ (it would start the next cookie-av), while RFC 3986 section 3.3
+ allows it in a path as a sub-delim. Percent-encoding it would
+ satisfy the grammar but no longer path-match the request, so
+ such a path simply cannot be expressed. */
+ GNUNET_free (res);
+ return NULL;
+ }
+ if ( ('%' == c) &&
+ (i + 2 < len) &&
+ (isxdigit ((unsigned char) path[i + 1])) &&
+ (isxdigit ((unsigned char) path[i + 2])) )
+ {
+ res[off++] = path[i];
+ res[off++] = path[i + 1];
+ res[off++] = path[i + 2];
+ i += 2;
+ continue;
+ }
+ if (path_char_literal_ok (c))
+ {
+ res[off++] = (char) c;
+ continue;
+ }
+ res[off++] = '%';
+ res[off++] = hex[c >> 4];
+ res[off++] = hex[c & 15];
+ }
+ res[off] = '\0';
+ return res;
+}
+
+
char *
PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time,
const char *website,
@@ -153,9 +268,27 @@ PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time,
char *end;
char cstr[128];
char *res;
+ char *epath = NULL;
const char *url = "/";
+ /* RFC 6265 section 4.1.2.5: `Secure` says the credential must never
+ leave the user agent over an unsecured channel. Deciding that from
+ the website URL alone would let whoever picked that URL decide it:
+ for an order carrying a fulfillment_url the string comes from the
+ order, and the pay endpoint only checks it against the contract.
+ BASE_URL is the operator's own statement about the scheme clients
+ reach us with -- including the usual deployment where a reverse
+ proxy terminates the TLS and we ourselves only ever see plaintext
+ -- so it wins wherever it is configured, and the website URL is
+ consulted only when it is not. Note that the transport itself
+ answers a different question: paivana never passes MHD_USE_TLS
+ (see PAIVANA_HTTPD_serve_requests()), so
+ #MHD_CONNECTION_INFO_PROTOCOL says "plaintext" in every deployment
+ that exists today, and taking it as the sole source would drop
+ `Secure` from every cookie we hand out. */
bool use_https = (0 ==
- strncasecmp (website,
+ strncasecmp ((NULL != PH_base_url)
+ ? PH_base_url
+ : website,
"https://",
strlen ("https://")));
struct GNUNET_TIME_Relative duration
@@ -165,11 +298,22 @@ PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time,
{
const char *dslash = strstr (website,
"//");
+ const char *path = NULL;
+
if (NULL != dslash)
- url = strchr (dslash + 2,
- '/');
- if (NULL == url)
- url = "/";
+ path = strchr (dslash + 2,
+ '/');
+ if (NULL != path)
+ {
+ epath = encode_path (path);
+ if (NULL != epath)
+ url = epath;
+ else
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Path of `%s' is not expressible as a cookie Path"
+ " attribute; scoping the access cookie to `/'\n",
+ website);
+ }
}
compute_cookie_hash (cur_time,
website,
@@ -196,6 +340,7 @@ PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time,
: "",
url,
(unsigned long long) (duration.rel_value_us / 1000 / 1000));
+ GNUNET_free (epath);
return res;
}
diff --git a/src/backend/paivana-httpd_cookie.h b/src/backend/paivana-httpd_cookie.h
@@ -70,13 +70,20 @@ PAIVANA_HTTPD_check_cookie (const char *cookie,
const void *ca);
/**
- * Compute access cookie hash for the given @a expiration and @a ca.
+ * Compute the `Set-Cookie` line granting access to @a website until
+ * @a cur_time for a client at @a ca.
*
- * @param expiration expiration time of the cookie
+ * The `Path` attribute is scoped to @a website (unless
+ * #PH_global_cookie), percent-encoded so that it path-matches the
+ * request URI the browser will send (RFC 6265 section 5.1.4), and
+ * falls back to "/" for a path that RFC 6265 section 4.1.1 cannot
+ * express. `Secure` follows #PH_base_url where that is configured.
+ *
+ * @param cur_time expiration time of the cookie
* @param website URL of the site the cookie is for
* @param ca_len number of bytes in @a ca
* @param ca client address
- * @param[out] c set to the cookie hash
+ * @return the value for the `Set-Cookie` header; the caller must free
*/
char *
PAIVANA_HTTPD_compute_cookie (struct GNUNET_TIME_Timestamp cur_time,
diff --git a/src/tests/meson.build b/src/tests/meson.build
@@ -52,6 +52,30 @@ test_client_address = executable(
test('client_address', test_client_address)
+# Unit test for the Set-Cookie line of the access cookie (Path
+# encoding, Path grammar, Secure). Links only the cookie compilation
+# unit; the daemon's globals are supplied by the test itself.
+test_cookie_header = executable(
+ 'test_cookie_header',
+ [
+ 'test_cookie_header.c',
+ '../backend/paivana-httpd_cookie.c',
+ ],
+ dependencies: [
+ talerutil_dep,
+ talermhd_dep,
+ gnunetutil_dep,
+ gcrypt_dep,
+ mhd_dep,
+ json_dep,
+ curl_dep,
+ ],
+ include_directories: [incdir, configuration_inc, paivana_backend_inc],
+ install: false,
+)
+
+test('cookie_header', test_cookie_header)
+
test_deps = [
upstream_mhd,
pipeline_client,
diff --git a/src/tests/test_cookie_header.c b/src/tests/test_cookie_header.c
@@ -0,0 +1,481 @@
+/*
+ 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` attribute and its `Secure` attribute
+ *
+ * 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.
+ *
+ * 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 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)
+{
+ static const uint8_t ca[4] = { 203, 0, 113, 7 };
+
+ return PAIVANA_HTTPD_compute_cookie (
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ website,
+ sizeof (ca),
+ ca);
+}
+
+
+/**
+ * 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;
+}
+
+
+/**
+ * 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,
+ "-- 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;
+}