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:
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 = ▮
+ 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 */