paivana

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

commit 66635b91012b8430e9b06da33446b7b63478a7c2
parent e51a4872e23736cf7701806ce17700005a21ab92
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 19:13:57 +0200

construct proper cookie path

Diffstat:
Msrc/backend/paivana-httpd_cookie.c | 155++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/backend/paivana-httpd_cookie.h | 13++++++++++---
Msrc/tests/meson.build | 24++++++++++++++++++++++++
Asrc/tests/test_cookie_header.c | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; +}