exchange

Base system with REST service to issue digital coins, run by the payment service provider
Log | Files | Refs | Submodules | README | LICENSE

commit 67feb2bf958446b5b6c1f65482a06b5d4374fb7a
parent f99ec17b49f472cacb940f189b59d1cccda858f4
Author: Florian Dold <dold@taler.net>
Date:   Tue, 28 Jul 2026 19:16:36 +0200

mhd: check content negotiation for legal documents

Issue: https://bugs.taler.net/n/11635

Diffstat:
Msrc/mhd/meson.build | 3++-
Asrc/mhd/test_legal.c | 450+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 452 insertions(+), 1 deletion(-)

diff --git a/src/mhd/meson.build b/src/mhd/meson.build @@ -53,7 +53,7 @@ pkg.generate( description: 'GNU Taler microhttpd library', ) -talermhd_tests = ['test_typst'] +talermhd_tests = ['test_typst', 'test_legal'] foreach t : talermhd_tests @@ -67,6 +67,7 @@ foreach t : talermhd_tests libtalerutil_dep, json_dep, mhd_dep, + curl_dep, ], include_directories: [incdir, configuration_inc], build_by_default: false, diff --git a/src/mhd/test_legal.c b/src/mhd/test_legal.c @@ -0,0 +1,450 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER 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. + + TALER 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ +/** + * @file mhd/test_legal.c + * @brief Tests for content negotiation on legal documents + * @author Christian Grothoff + */ +#include "platform.h" +#include <gnunet/gnunet_util_lib.h> +#include <microhttpd.h> +#include <curl/curl.h> +#include "taler/taler_util.h" +#include "taler/taler_mhd_lib.h" + +/** + * Version (Etag) of the terms we serve in this test. + */ +#define TERMS_VERSION "test-tos-v0" + +/** + * Terms of service we serve during the test. + */ +static struct TALER_MHD_Legal *legal; + +/** + * Directory with the terms of service. + */ +static char *tmpdir; + +/** + * Return value of the test. + */ +static int global_ret; + + +/** + * Response headers we care about. + */ +struct Headers +{ + /** + * Value of the "Content-Type" header, or empty. + */ + char content_type[128]; + + /** + * Value of the "Content-Language" header, or empty. + */ + char content_language[128]; +}; + + +/** + * Extract @a name from the header line in @a buf of @a len bytes + * into @a dst of @a dst_size bytes. + */ +static void +extract_header (const char *buf, + size_t len, + const char *name, + char *dst, + size_t dst_size) +{ + size_t nlen = strlen (name); + size_t vlen; + + if ( (len < nlen + 1) || + (0 != strncasecmp (buf, + name, + nlen)) || + (':' != buf[nlen]) ) + return; + buf += nlen + 1; + len -= nlen + 1; + while ( (0 != len) && + (isspace ((int) *buf)) ) + { + buf++; + len--; + } + while ( (0 != len) && + (isspace ((int) buf[len - 1])) ) + len--; + vlen = GNUNET_MIN (len, + dst_size - 1); + memcpy (dst, + buf, + vlen); + dst[vlen] = '\0'; +} + + +/** + * Function called by CURL for each response header. + */ +static size_t +header_cb (char *buffer, + size_t size, + size_t nitems, + void *userdata) +{ + struct Headers *h = userdata; + size_t len = size * nitems; + + extract_header (buffer, + len, + "Content-Type", + h->content_type, + sizeof (h->content_type)); + extract_header (buffer, + len, + "Content-Language", + h->content_language, + sizeof (h->content_language)); + return len; +} + + +/** + * Function called by CURL with the response body; we discard it. + */ +static size_t +body_cb (char *buffer, + size_t size, + size_t nitems, + void *userdata) +{ + (void) buffer; + (void) userdata; + return size * nitems; +} + + +/** + * Handle a request by returning our terms of service. + */ +static enum MHD_Result +access_handler (void *cls, + struct MHD_Connection *connection, + const char *url, + const char *method, + const char *version, + const char *upload_data, + size_t *upload_data_size, + void **con_cls) +{ + static int marker; + + (void) cls; + (void) url; + (void) method; + (void) version; + (void) upload_data; + (void) upload_data_size; + if (NULL == *con_cls) + { + *con_cls = &marker; + return MHD_YES; + } + return TALER_MHD_reply_legal (connection, + legal); +} + + +/** + * Write @a content to the file @a tmpdir/@a lang/@a name. + */ +static enum GNUNET_GenericReturnValue +write_terms (const char *lang, + const char *name, + const char *content) +{ + char *dn; + char *fn; + enum GNUNET_GenericReturnValue ret = GNUNET_SYSERR; + + GNUNET_asprintf (&dn, + "%s/%s", + tmpdir, + lang); + GNUNET_asprintf (&fn, + "%s/%s", + dn, + name); + if (GNUNET_OK == + GNUNET_DISK_directory_create (dn)) + ret = (GNUNET_SYSERR == + GNUNET_DISK_fn_write (fn, + content, + strlen (content), + GNUNET_DISK_PERM_USER_READ + | GNUNET_DISK_PERM_USER_WRITE)) + ? GNUNET_SYSERR + : GNUNET_OK; + GNUNET_free (fn); + GNUNET_free (dn); + return ret; +} + + +/** + * Fetch the terms of service from the daemon at @a port using + * @a accept and @a accept_language, and check that the response + * carries @a want_type and @a want_language. + * + * @param port port the daemon listens on + * @param accept value for the "Accept" header, NULL to send none + * @param accept_language value for "Accept-Language", NULL to send none + * @param want_type expected "Content-Type" + * @param want_language expected "Content-Language" + */ +static enum GNUNET_GenericReturnValue +check_negotiation (uint16_t port, + const char *accept, + const char *accept_language, + const char *want_type, + const char *want_language) +{ + CURL *eh; + CURLcode res; + struct curl_slist *hdrs = NULL; + struct Headers h = { 0 }; + char *url; + char *hdr; + long code; + + GNUNET_asprintf (&url, + "http://localhost:%u/terms", + (unsigned int) port); + eh = curl_easy_init (); + GNUNET_assert (NULL != eh); + /* CURL always sends a default wildcard "Accept" header; + passing the header name with an empty value drops it. */ + GNUNET_asprintf (&hdr, + "Accept:%s%s", + (NULL != accept) ? " " : "", + (NULL != accept) ? accept : ""); + hdrs = curl_slist_append (hdrs, + hdr); + GNUNET_free (hdr); + if (NULL != accept_language) + { + GNUNET_asprintf (&hdr, + "Accept-Language: %s", + accept_language); + hdrs = curl_slist_append (hdrs, + hdr); + GNUNET_free (hdr); + } + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, CURLOPT_URL, url)); + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, CURLOPT_HTTPHEADER, hdrs)); + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, CURLOPT_HEADERFUNCTION, &header_cb)); + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, CURLOPT_HEADERDATA, &h)); + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, CURLOPT_WRITEFUNCTION, &body_cb)); + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, CURLOPT_TIMEOUT, 30L)); + res = curl_easy_perform (eh); + if (CURLE_OK != res) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Failed to fetch `%s': %s\n", + url, + curl_easy_strerror (res)); + curl_easy_cleanup (eh); + curl_slist_free_all (hdrs); + GNUNET_free (url); + return GNUNET_SYSERR; + } + GNUNET_assert (CURLE_OK == + curl_easy_getinfo (eh, CURLINFO_RESPONSE_CODE, &code)); + curl_easy_cleanup (eh); + curl_slist_free_all (hdrs); + GNUNET_free (url); + if (MHD_HTTP_OK != code) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected HTTP status %ld for Accept: %s\n", + code, + (NULL != accept) ? accept : "<none>"); + return GNUNET_SYSERR; + } + if (0 != strcmp (want_type, + h.content_type)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Accept: %s / Accept-Language: %s returned `%s', wanted `%s'\n", + (NULL != accept) ? accept : "<none>", + (NULL != accept_language) ? accept_language : "<none>", + h.content_type, + want_type); + return GNUNET_SYSERR; + } + if (0 != strcmp (want_language, + h.content_language)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Accept: %s / Accept-Language: %s returned language `%s', wanted `%s'\n", + (NULL != accept) ? accept : "<none>", + (NULL != accept_language) ? accept_language : "<none>", + h.content_language, + want_language); + return GNUNET_SYSERR; + } + return GNUNET_OK; +} + + +int +main (int argc, + const char *const argv[]) +{ + struct GNUNET_CONFIGURATION_Handle *cfg; + struct MHD_Daemon *d; + uint16_t port; + + (void) argc; + (void) argv; + GNUNET_log_setup ("test-legal", + "WARNING", + NULL); + GNUNET_assert (CURLE_OK == + curl_global_init (CURL_GLOBAL_DEFAULT)); + tmpdir = GNUNET_DISK_mkdtemp ("taler-test-legal"); + GNUNET_assert (NULL != tmpdir); + /* Order of creation is deliberately "PDF last", as that is what + exposed the format-preference bug. */ + GNUNET_assert (GNUNET_OK == + write_terms ("en", + TERMS_VERSION ".txt", + "terms in plain text")); + GNUNET_assert (GNUNET_OK == + write_terms ("en", + TERMS_VERSION ".html", + "<html><body>terms</body></html>")); + GNUNET_assert (GNUNET_OK == + write_terms ("en", + TERMS_VERSION ".md", + "# terms")); + GNUNET_assert (GNUNET_OK == + write_terms ("en", + TERMS_VERSION ".pdf", + "%PDF-1.4 terms")); + GNUNET_assert (GNUNET_OK == + write_terms ("de", + TERMS_VERSION ".txt", + "AGB im Textformat")); + GNUNET_assert (GNUNET_OK == + write_terms ("de", + TERMS_VERSION ".pdf", + "%PDF-1.4 AGB")); + cfg = GNUNET_CONFIGURATION_create (TALER_EXCHANGE_project_data ()); + GNUNET_CONFIGURATION_set_value_string (cfg, + "test", + "TERMS_DIR", + tmpdir); + GNUNET_CONFIGURATION_set_value_string (cfg, + "test", + "TERMS_ETAG", + TERMS_VERSION); + legal = TALER_MHD_legal_load (cfg, + "test", + "TERMS_DIR", + "TERMS_ETAG"); + GNUNET_CONFIGURATION_destroy (cfg); + GNUNET_assert (NULL != legal); + d = MHD_start_daemon (MHD_USE_INTERNAL_POLLING_THREAD, + 0 /* any free port */, + NULL, NULL, + &access_handler, NULL, + MHD_OPTION_END); + GNUNET_assert (NULL != d); + { + const union MHD_DaemonInfo *di; + + di = MHD_get_daemon_info (d, + MHD_DAEMON_INFO_BIND_PORT); + GNUNET_assert (NULL != di); + port = di->port; + } + + { + struct + { + const char *accept; + const char *accept_language; + const char *want_type; + const char *want_language; + } tests[] = { + /* A client without a format preference (curl, browsers, the + wallet) must not be served the PDF. */ + { "*/*", NULL, "text/plain", "en" }, + /* No "Accept" header at all. */ + { NULL, NULL, "text/plain", "en" }, + /* A wildcard within "text" must not pick HTML over plain text. */ + { "text/*", NULL, "text/plain", "en" }, + /* An explicit preference still wins over our own ranking. */ + { "application/pdf", NULL, "application/pdf", "en" }, + { "text/html", NULL, "text/html", "en" }, + { "text/markdown", NULL, "text/markdown", "en" }, + { "text/plain;q=0.1, application/pdf;q=0.9", NULL, + "application/pdf", "en" }, + /* Weights are the only way a client can convey a preference order, + so they must decide both ways round -- including against our own + ranking, which puts HTML above Markdown. */ + { "text/markdown, text/html;q=0.9", NULL, "text/markdown", "en" }, + { "text/html, text/markdown;q=0.9", NULL, "text/html", "en" }, + /* Format preference must not override the language preference. */ + { "*/*", "de", "text/plain", "de" }, + { "application/pdf", "de", "application/pdf", "de" }, + { NULL, NULL, NULL, NULL } + }; + + for (unsigned int i = 0; NULL != tests[i].want_type; i++) + if (GNUNET_OK != + check_negotiation (port, + tests[i].accept, + tests[i].accept_language, + tests[i].want_type, + tests[i].want_language)) + global_ret = 1; + } + MHD_stop_daemon (d); + TALER_MHD_legal_free (legal); + GNUNET_assert (GNUNET_OK == + GNUNET_DISK_directory_remove (tmpdir)); + GNUNET_free (tmpdir); + curl_global_cleanup (); + return global_ret; +} + + +/* end of test_legal.c */