paivana

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

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

misc robustness fixes

Diffstat:
Msrc/backend/paivana-httpd.c | 17+++++++++++++++++
Msrc/backend/paivana-httpd_reverse.c | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/backend/paivana-httpd_templates.c | 185++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/tests/test_reverse_proxy.sh | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 399 insertions(+), 22 deletions(-)

diff --git a/src/backend/paivana-httpd.c b/src/backend/paivana-httpd.c @@ -402,6 +402,23 @@ run (void *cls, GNUNET_SCHEDULER_shutdown (); return; } + /* Deliberately *not* run through strip_trailing_slashes(): a Taler + merchant API base URL is joined with relative paths, so it wants + the '/' the other base URLs above shed. TALER_url_join() insists + on it and returns NULL without it, which surfaces much later as + an assertion failure on the handle built from the joined URL -- + so check for it here, where we can still name the option that is + wrong. */ + if ('/' != PH_merchant_base_url[strlen (PH_merchant_base_url) - 1]) + { + GNUNET_log_config_invalid (GNUNET_ERROR_TYPE_ERROR, + "paivana", + "MERCHANT_BACKEND_URL", + "must end with a '/'"); + PH_global_ret = EXIT_NOTCONFIGURED; + GNUNET_SCHEDULER_shutdown (); + return; + } } { char *merchant_unix_path; diff --git a/src/backend/paivana-httpd_reverse.c b/src/backend/paivana-httpd_reverse.c @@ -49,6 +49,14 @@ __LINE__, \ curl_easy_strerror (rc)) +/** + * Value of `upstream_content_length` when the upstream declared no + * usable `Content-Length` for its response. Not a length any message + * can have: RFC 9110 §8.6 makes the field a count of octets, and we + * never buffer more than #GNUNET_MAX_MALLOC_CHECKED of them. + */ +#define PH_NO_CONTENT_LENGTH UINT64_MAX + /** * State machine for HTTP requests (per request). MHD invokes the @@ -251,6 +259,16 @@ struct HttpRequest unsigned int response_code; /** + * `Content-Length` the upstream declared for its response, or + * #PH_NO_CONTENT_LENGTH if it declared none we could parse. + * `curl_check_hdr()` drops the header itself — the length we + * forward has to describe the buffer we actually assembled — but + * the declared value is kept here because it is the only evidence + * that the buffer is short; see `curl_download_cb()`. + */ + uint64_t upstream_content_length; + + /** * Request processing state machine. */ enum RequestState state; @@ -261,6 +279,27 @@ struct HttpRequest enum GNUNET_GenericReturnValue suspended; /** + * Is @e response ours to destroy? + * + * False for the four shared error responses, which outlive any one + * request. This used to be inferred by comparing @e response + * against those four statics, which only worked while they were + * non-NULL: #PAIVANA_HTTPD_reverse_shutdown() clears them while MHD + * may still hold queued references, and from then on every + * comparison matched nothing and cleanup destroyed a response it + * never owned. + */ + bool own_response; + + /** + * Was the client's method HEAD? A response to HEAD never carries a + * body, however large a `Content-Length` it declares (RFC 9110 + * §9.3.2), so the truncation check in `curl_download_cb()` must not + * be applied to it. + */ + bool head_request; + + /** * Concatenated value of the client's Via header(s), if any. Per * RFC 9110 §7.6.3 a proxy must *append* its own entry to this * list, not replace it; we capture the inbound value here before @@ -462,6 +501,14 @@ PAIVANA_HTTPD_reverse_shutdown (void) MHD_resume_connection (hr->con); } } + /* Dropping *our* reference to the shared responses here is safe even + though MHD is stopped only afterwards: a queued response is + reference-counted (MHD_queue_response() takes its own reference), + so what follows merely relinquishes ownership. What must not + happen is a second release from + #PAIVANA_HTTPD_reverse_cleanup() — hence the explicit + `own_response` rather than a pointer comparison against the + statics we are about to NULL out. */ if (NULL != curl_failure_response) { MHD_destroy_response (curl_failure_response); @@ -733,10 +780,24 @@ curl_check_hdr (void *buffer, hdr_type, hdr_val); /* Skip "Content-length:" header as it will be wrong, given - that we are man-in-the-middling the connection */ + that we are man-in-the-middling the connection. Remember the + value first: it is what lets `curl_download_cb()' tell a complete + body from one libgnunetcurl stopped buffering (RFC 9110 §8.6 — + the field states the length of the body that follows). */ if (0 == strcasecmp (hdr_type, MHD_HTTP_HEADER_CONTENT_LENGTH)) { + char *endptr; + unsigned long long cl; + + errno = 0; + cl = strtoull (hdr_val, + &endptr, + 10); + if ( (0 == errno) && + (endptr != hdr_val) && + ('\0' == *endptr) ) + hr->upstream_content_length = (uint64_t) cl; GNUNET_free (ndup); return bytes; } @@ -775,6 +836,26 @@ curl_check_hdr (void *buffer, /** + * Can a response with status @a code carry a body at all? + * + * RFC 9110 §6.4.1: 1xx, 204 and 304 responses never do. §8.6 even + * allows a 304 to state the `Content-Length` the full representation + * would have, which is precisely a declared length that no body + * follows — so these have to be excluded before comparing the two. + * + * @param code HTTP status code the upstream returned + * @return true if a response body is to be expected + */ +static bool +status_allows_body (unsigned int code) +{ + return (code >= MHD_HTTP_OK) && + (MHD_HTTP_NO_CONTENT != code) && + (MHD_HTTP_NOT_MODIFIED != code); +} + + +/** * Handle response payload data from cURL. * * @param cls our `struct HttpRequest *` @@ -804,6 +885,54 @@ curl_download_cb (void *cls, hr->state = REQUEST_STATE_PROXY_DOWNLOAD_FAILED; return; } + /* libgnunetcurl stops buffering at #GNUNET_MAX_MALLOC_CHECKED (40 + MiB) by returning 0 to libcurl, which fails the transfer — but + CURLINFO_RESPONSE_CODE still holds the status line libcurl parsed + long before, and GNUNET_CURL_perform2() hands us that code + together with the partial buffer. Nothing else here distinguishes + a truncated body from a complete one, and MHD would go on to + compute a perfectly consistent Content-Length for the fragment: + the client would receive a corrupt representation under the + upstream's 2xx with no signal at all. RFC 9112 §8.1.2 requires an + incomplete message to be treated as a failure, so fail it. The + same comparison catches an upstream whose declared length and body + simply disagree, which is the same framing error. */ + if ( (PH_NO_CONTENT_LENGTH != hr->upstream_content_length) && + (! hr->head_request) && + (status_allows_body ((unsigned int) response_code)) && + (hr->upstream_content_length != (uint64_t) body_size) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Upstream declared %llu bytes for `%s' but delivered %llu;" + " failing the request rather than serving a partial body\n", + (unsigned long long) hr->upstream_content_length, + hr->url, + (unsigned long long) body_size); + hr->state = REQUEST_STATE_PROXY_DOWNLOAD_FAILED; + return; + } + /* A chunked upstream states no length at all, so the comparison + above cannot see the ceiling being reached. What is still visible + is how close we came to it: libgnunetcurl refuses the write that + *would* reach #GNUNET_MAX_MALLOC_CHECKED, and libcurl hands it at + most CURL_MAX_WRITE_SIZE per call, so every truncated body ends up + within that much of the ceiling. Bodies that merely happen to + fall in the same narrow band are refused along with them: at 16 kb + below a 40 MiB cap that is a fair price for not serving a + silently-cut representation. */ + if ( ((uint64_t) body_size) + CURL_MAX_WRITE_SIZE >= + (uint64_t) GNUNET_MAX_MALLOC_CHECKED) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Response for `%s' reached the %llu byte buffering limit" + " at %llu bytes; failing the request rather than serving" + " a partial body\n", + hr->url, + (unsigned long long) GNUNET_MAX_MALLOC_CHECKED, + (unsigned long long) body_size); + hr->state = REQUEST_STATE_PROXY_DOWNLOAD_FAILED; + return; + } hr->response = MHD_create_response_from_buffer_copy (body_size, body); if (NULL == hr->response) @@ -814,6 +943,7 @@ curl_download_cb (void *cls, hr->state = REQUEST_STATE_PROXY_DOWNLOAD_FAILED; return; } + hr->own_response = true; hr->response_code = response_code; /* RFC 9110 §7.6.1: drop the headers named by the upstream's Connection list. Deferred to here (rather than done in @@ -1185,6 +1315,7 @@ PAIVANA_HTTPD_reverse_create (struct MHD_Connection *connection, hr = GNUNET_new (struct HttpRequest); hr->state = REQUEST_STATE_HEADERS_PENDING; + hr->upstream_content_length = PH_NO_CONTENT_LENGTH; hr->con = connection; hr->url = GNUNET_strdup (url); GNUNET_CONTAINER_DLL_insert (hr_head, @@ -1218,11 +1349,10 @@ PAIVANA_HTTPD_reverse_cleanup (struct HttpRequest *hr) hr->headers = NULL; } if ( (NULL != hr->response) && - (curl_failure_response != hr->response) && - (method_failure_response != hr->response) && - (upload_failure_response != hr->response) && - (internal_failure_response != hr->response) ) - /* Destroy non-error responses... (?) */ + (hr->own_response) ) + /* The shared error responses belong to this module, not to the + request that queued one; releasing them here would take a + reference we never held. */ MHD_destroy_response (hr->response); for (header = hr->header_head; @@ -1405,6 +1535,9 @@ configure_curl_method (struct HttpRequest *hr, MHD_HTTP_METHOD_HEAD)) { hr->state = REQUEST_STATE_PROXY_DOWNLOAD_STARTED; + /* The upstream still states the length the equivalent GET would + have had; nothing follows it (RFC 9110 §9.3.2). */ + hr->head_request = true; curl_easy_setopt (hr->curl, CURLOPT_NOBODY, 1L); diff --git a/src/backend/paivana-httpd_templates.c b/src/backend/paivana-httpd_templates.c @@ -54,6 +54,25 @@ struct Template; */ #define MAX_RESPONSE_CACHE_ENTRIES 128 +/** + * How long we give the merchant backend to answer our template + * queries before abandoning the startup sequence. + * + * Nothing is served until they are in: #PAIVANA_HTTPD_serve_requests() + * — which binds the listen sockets — is only reached from the last of + * these callbacks, and neither `TALER_MERCHANT_curl_easy_get_()' nor + * anything above it arms CURLOPT_TIMEOUT. A backend that accepts the + * TCP connection and then never answers would otherwise leave paivana + * neither serving nor exiting, with no log line after the startup + * banner; under `SERVE = systemd' the listening socket already + * exists, so clients connect successfully and then hang forever with + * nothing accepting them. Generous enough for the round-trips a load + * takes (GET /private/templates, then one GET per template, issued in + * parallel). + */ +#define TEMPLATE_LOAD_TIMEOUT \ + GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MINUTES, 2) + /** * Entry in the cache of responses for a given template. @@ -178,6 +197,56 @@ static struct Template *t_tail; */ static struct TALER_MERCHANT_GetPrivateTemplatesHandle *gpt; +/** + * Watchdog for #TEMPLATE_LOAD_TIMEOUT, NULL once the templates are in + * (or once we have given up on them). + */ +static struct GNUNET_SCHEDULER_Task *load_timeout_task; + + +/** + * Task run when the merchant backend did not answer our template + * queries within #TEMPLATE_LOAD_TIMEOUT. + * + * Treated exactly like any other failure to load the templates (an + * unauthorized or unexpected status from the backend): the daemon + * exits with a diagnosis instead of stalling. Whether that policy is + * the right one is a separate question — this task only makes sure the + * stall is not a third, silent outcome. + * + * @param cls NULL + */ +static void +load_timeout (void *cls) +{ + (void) cls; + load_timeout_task = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Merchant backend at `%s' did not answer our template queries" + " within %s; giving up instead of never starting to serve\n", + PH_merchant_base_url, + GNUNET_STRINGS_relative_time_to_string (TEMPLATE_LOAD_TIMEOUT, + true)); + PH_global_ret = EXIT_FAILURE; + GNUNET_SCHEDULER_shutdown (); +} + + +/** + * The templates are in (or there are none): stop the watchdog and + * open the listen sockets. + */ +static void +templates_ready (void) +{ + if (NULL != load_timeout_task) + { + GNUNET_SCHEDULER_cancel (load_timeout_task); + load_timeout_task = NULL; + } + PAIVANA_HTTPD_serve_requests (); +} + /** * Check if two strings are equal, including both being NULL @@ -347,13 +416,31 @@ load_paywall (struct MHD_Connection *conn, NULL /* no Taler URI (needs dynamic paivana_id!) */, data, &reply); + json_decref (data); + if (GNUNET_NO == ret) + { + enum MHD_Result mret; + + /* taler_templating_lib.h: #GNUNET_NO means an *error reply* was + built — typically because the paywall template is not + installed where TALER_TEMPLATING_init() looked. It is a live + MHD_Response and it is ours: returning #MHD_YES without + queuing it leaked it once per unauthenticated request and left + the client waiting for a status line that never came. */ + GNUNET_break (0); + mret = MHD_queue_response (conn, + http_status, + reply); + MHD_destroy_response (reply); + return mret; + } if (GNUNET_OK != ret) { + /* #GNUNET_SYSERR: no reply was built, so there is nothing to + queue and nothing to free; MHD must close the connection. */ GNUNET_break (0); - json_decref (data); - return (GNUNET_NO == ret) ? MHD_YES : MHD_NO; + return MHD_NO; } - json_decref (data); } @@ -476,6 +563,30 @@ parse_template (struct Template *t, en); return false; } + /* GNUNET_JSON_spec_array_const() only establishes that "choices" is + an array; what is *in* it is whatever the backend sent. + load_paywall() hands element 0 to + GNUNET_JSON_pack_object_incref(), which aborts on anything that is + not an object — and it does so from an unauthenticated GET of the + paywall page, so a backend that disagrees with us about the schema + would turn every visitor into a crash loop. We cross a network + trust boundary here and must not assert on what comes back. */ + { + size_t idx; + json_t *choice; + + json_array_foreach ((json_t *) choices, idx, choice) + { + if (json_is_object (choice)) + continue; + GNUNET_break_op (0); + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Template %s has a non-object choice at index %u\n", + t->template_id, + (unsigned int) idx); + return false; + } + } if ( (NULL != regex) && ('\0' != regex[0]) ) { @@ -560,7 +671,7 @@ setup_template ( /* all templates done, continue with main logic */ GNUNET_log (GNUNET_ERROR_TYPE_INFO, "Templates loaded, starting to serve requests\n"); - PAIVANA_HTTPD_serve_requests (); + templates_ready (); } @@ -583,7 +694,7 @@ check_templates ( case MHD_HTTP_NO_CONTENT: GNUNET_log (GNUNET_ERROR_TYPE_WARNING, "No templates found, starting to serve requests\n"); - PAIVANA_HTTPD_serve_requests (); + templates_ready (); return; case MHD_HTTP_UNAUTHORIZED: GNUNET_log (GNUNET_ERROR_TYPE_ERROR, @@ -605,7 +716,7 @@ check_templates ( { GNUNET_log (GNUNET_ERROR_TYPE_WARNING, "No templates found, starting to serve requests\n"); - PAIVANA_HTTPD_serve_requests (); + templates_ready (); return; } @@ -636,6 +747,11 @@ check_templates ( void PAIVANA_HTTPD_load_templates () { + GNUNET_assert (NULL == load_timeout_task); + load_timeout_task + = GNUNET_SCHEDULER_add_delayed (TEMPLATE_LOAD_TIMEOUT, + &load_timeout, + NULL); gpt = TALER_MERCHANT_get_private_templates_create (PH_merchant_ctx, PH_merchant_base_url); GNUNET_assert (NULL != gpt); @@ -679,17 +795,49 @@ PAIVANA_HTTPD_search_templates (struct MHD_Connection *connection, char *enc = NULL; char *url; - if ( (NULL != t->regex) && - (0 != regexec (&t->ex, - website, - 0, NULL, - 0)) ) + if (NULL != t->regex) { - GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, - "Request for %s did not match template %s\n", - website, - t->template_id); - continue; + int rc; + + rc = regexec (&t->ex, + website, + 0, NULL, + 0); + if (REG_NOMATCH == rc) + { + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "Request for %s did not match template %s\n", + website, + t->template_id); + continue; + } + if (0 != rc) + { + char errbuf[128]; + + /* Not "did not match": regexec(3) also reports REG_ESPACE, + whose likelihood is a function of the merchant's pattern and + the client's URL. Taking the `continue' would drop this + template from consideration, and if it were the last one the + caller reads #GNUNET_SYSERR as "no paywall applies" and + serves the page for free. Fail closed instead. */ + GNUNET_break (0); + (void) regerror (rc, + &t->ex, + errbuf, + sizeof (errbuf)); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Failed to match template %s against %s: %s\n", + t->template_id, + website, + errbuf); + ret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, + errbuf); + return (MHD_YES == ret) ? GNUNET_OK : GNUNET_NO; + } } if (! PAIVANA_HTTPD_get_base_url (connection, @@ -778,6 +926,11 @@ PAIVANA_HTTPD_return_template (struct MHD_Connection *connection, void PAIVANA_HTTPD_unload_templates () { + if (NULL != load_timeout_task) + { + GNUNET_SCHEDULER_cancel (load_timeout_task); + load_timeout_task = NULL; + } while (NULL != t_head) { struct Template *t = t_head; diff --git a/src/tests/test_reverse_proxy.sh b/src/tests/test_reverse_proxy.sh @@ -82,6 +82,7 @@ PY_PORT=18403 RS_PORT=18404 EARLY_PORT=18405 NODRAIN_PORT=18406 +TRUNC_PORT=18407 DEAD_PORT=18499 # nothing should be listening here TMPDIR="$(mktemp -d -t paivana-tests.XXXXXX)" @@ -796,6 +797,76 @@ function test_early_response_no_drain() { } +function start_truncating_upstream() { + # An upstream that declares more body than it delivers and then + # closes: the wire shape of a response that arrived incomplete. + # It is also exactly what paivana ends up holding when + # libgnunetcurl stops buffering at its 40 MiB ceiling -- libcurl + # fails the transfer, but the status line it parsed long before is + # still what CURLINFO_RESPONSE_CODE reports. + local port="$1" + cat >"$TMPDIR/truncating_upstream.py" <<'PYEOF' +import socket +import sys + +port = int(sys.argv[1]) +srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(("127.0.0.1", port)) +srv.listen(5) +while True: + conn, _ = srv.accept() + try: + conn.recv(65536) + conn.sendall(b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 100000\r\n" + b"\r\n" + + b"x" * 1000) + except OSError: + pass + conn.close() +PYEOF + local log="$LOGDIR/truncating.log" + ( exec python3 "$TMPDIR/truncating_upstream.py" "$port" ) >"$log" 2>&1 & + PIDS+=("$!") + if ! wait_for_port 127.0.0.1 "$port"; + then + echo "FAIL: truncating upstream did not start on port $port" >&2 + tail -n 20 "$log" >&2 + exit 1 + fi +} + +function test_short_body() { + # An incomplete message must be treated as a failure (RFC 9112 + # section 8.1.2). Without that, paivana served the fragment under + # the upstream's 200 with a Content-Length MHD had computed for + # what was left -- a corrupt representation the client has no way + # of telling apart from a complete one. + msg "upstream body shorter than its Content-Length yields 502" + if ! command -v python3 >/dev/null 2>&1; + then + echo "SKIP (python3 missing)" + return + fi + stop_paivana + start_truncating_upstream "$TRUNC_PORT" + start_paivana "http://127.0.0.1:$TRUNC_PORT" + local status + status="$(curl -sS -o "$TMPDIR/body" -w '%{http_code}' \ + --max-time 30 \ + "$(PAIVANA_URL /short)" 2>"$TMPDIR/err")" \ + || fail "curl: $(cat "$TMPDIR/err")" + [ "$status" = "502" ] || \ + fail "status=$status want=502 (a 200 here means the partial body was served as if complete)" + grep -qi 'bad gateway' "$TMPDIR/body" || \ + fail "no 'Bad Gateway' in body" + ok + stop_paivana +} + + function test_upstream_down() { msg "upstream down yields 502 Bad Gateway" # Re-point paivana at a port with nothing listening. @@ -1233,6 +1304,9 @@ fi test_early_response test_early_response_no_drain +# --- Truncated-response test (restarts paivana) ----------------------- +test_short_body + # --- Upstream-down test (runs last because it restarts paivana) ------- test_upstream_down stop_paivana