paivana

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

commit 379078c615854f8850a069dd7fee78827e376e12
parent 5dfdaf05ea095c7d26e1a48e76bb2a04d2c62191
Author: Christian Grothoff <christian@grothoff.org>
Date:   Tue,  4 Aug 2026 16:53:37 +0200

make URL parsing more robust, using libcurl parser

Diffstat:
MREADME | 2+-
Mmeson.build | 10++++++----
Msrc/backend/paivana-httpd_reverse.c | 69+++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/tests/README | 11+++++++++++
Msrc/tests/test_reverse_proxy.sh | 21+++++++++++++++++++++
5 files changed, 90 insertions(+), 23 deletions(-)

diff --git a/README b/README @@ -46,7 +46,7 @@ Dependencies - GNUnet (libgnunetutil, libgnunetcurl) - libmicrohttpd -- libcurl >= 7.34.0 +- libcurl >= 7.62.0 - libjansson - libgcrypt >= 1.6.1 - zlib diff --git a/meson.build b/meson.build @@ -110,13 +110,15 @@ if not get_option('only-doc') gnunetjson_dep = cc.find_library('gnunetjson', required: true) endif - curl_dep = dependency('libcurl', version: '>=7.34.0', required: false) + # 7.62.0 is where the curl_url_*() parser appeared; we use it to + # derive the upstream Host header. + curl_dep = dependency('libcurl', version: '>=7.62.0', required: false) if not curl_dep.found() curl_dep = cc.find_library('curl', required: true) curl_version_check = '''#include <curl/curl.h> int main(int argc, char **argv) { - #if LIBCURL_VERSION_NUM < 0x073400 - #error "cURL version >= 7.34.0 required" + #if LIBCURL_VERSION_NUM < 0x073e00 + #error "cURL version >= 7.62.0 required" #endif return 0; } @@ -126,7 +128,7 @@ if not get_option('only-doc') name: 'cURL version check', dependencies: curl_dep, ) - error('cURL version >=7.34.0 required') + error('cURL version >=7.62.0 required') endif endif diff --git a/src/backend/paivana-httpd_reverse.c b/src/backend/paivana-httpd_reverse.c @@ -863,36 +863,69 @@ curl_upload_cb (void *buf, /* ************** helper functions ************* */ /** - * Extract the hostname from a complete URL. + * Build the `Host:` header line to send upstream, naming the + * authority of @a url. + * + * The value is the host and — only if @a url states one explicitly — + * the port. Everything else in the authority must be left out: RFC + * 9110 §7.2 forbids userinfo in `Host:`, so a base URL of + * "http://user:pw@origin/" still has to yield "Host: origin". An + * IPv6 literal conversely has to *keep* its brackets (RFC 3986 + * §3.2.2), as the colons would otherwise be read as a port + * delimiter. Both are why the authority is taken from libcurl's URL + * parser instead of being cut out of the string by hand. * * @param url full fledged URL - * @return pointer to the 0-terminated hostname, to be freed - * by the caller. + * @return the header line, or NULL if @a url has no usable + * authority; to be freed by the caller */ static char * build_host_header (const char *url) { - #define MARKER "://" - + CURLU *h; + char *host; + char *port = NULL; char *header; - char *end; - char *hostname; - char *dup = GNUNET_strdup (url); - hostname = strstr (dup, - MARKER); - if (NULL == hostname) + h = curl_url (); + if (NULL == h) { - GNUNET_free (dup); + GNUNET_break (0); + return NULL; + } + if ( (CURLUE_OK != + curl_url_set (h, + CURLUPART_URL, + url, + 0)) || + (CURLUE_OK != + curl_url_get (h, + CURLUPART_HOST, + &host, + 0)) ) + { + /* PH_target_server_base_url passed TALER_is_web_url() at + startup, so failing to parse it here would be a bug. */ + GNUNET_break (0); + curl_url_cleanup (h); return NULL; } - hostname += 3; - end = strchrnul (hostname, '/'); - *end = '\0'; + /* Absent unless the URL spells the port out; libcurl elides the + scheme's default, which is what an origin expects to see. */ + if (CURLUE_OK != + curl_url_get (h, + CURLUPART_PORT, + &port, + 0)) + port = NULL; GNUNET_asprintf (&header, - "Host: %s", - hostname); - GNUNET_free (dup); + "Host: %s%s%s", + host, + (NULL != port) ? ":" : "", + (NULL != port) ? port : ""); + curl_free (host); + curl_free (port); + curl_url_cleanup (h); return header; } diff --git a/src/tests/README b/src/tests/README @@ -85,6 +85,17 @@ Per-upstream battery (`run_battery`): the round-trip GET /echo-headers paivana adds the reverse-proxy headers X-Forwarded-For, X-Forwarded-Proto, Via + Host: rewritten the Host the upstream sees is the + authority of DESTINATION_BASE_URL, not + the one the client dialed, and is + host[:port] and nothing else (RFC 9110 + §7.2 — no userinfo, no path, no query). + Note this only covers the destination + URLs paivana will actually accept: + TALER_is_web_url() rejects userinfo and + IPv6-literal DESTINATION_BASE_URLs at + startup, so those cannot be reached + from the driver. custom X-Test header arbitrary client request headers are forwarded unchanged X-Upstream response header upstream response headers survive the diff --git a/src/tests/test_reverse_proxy.sh b/src/tests/test_reverse_proxy.sh @@ -98,6 +98,10 @@ export PAIVANA_BASE_CONFIG="$BASE_CONFIG_DIR" PIDS=() PAIVANA_PID="" +# DESTINATION_BASE_URL the running paivana was configured with; the +# battery derives from it the Host header paivana should be sending +# upstream. +PAIVANA_DEST="" function dump_logs() { echo "-- logs in $LOGDIR --" >&2 @@ -171,6 +175,7 @@ function start_paivana() { # than a single "$flags" string) is what keeps a caller that # passes no extra flags from handing paivana an empty argument. local dest="$1"; shift + PAIVANA_DEST="$dest" local cfg="$TMPDIR/paivana.conf" sed -e "s|@DEST@|$dest|g" -e "s|@PORT@|$PAIVANA_PORT|g" \ "$SRCDIR/test_reverse_proxy.conf.in" > "$cfg" @@ -360,6 +365,22 @@ function run_battery() { fail "upstream did not see Via: paivana" ok + # RFC 9110 §7.2: the Host we send upstream names the *upstream* + # authority, not the one the client dialed, and carries nothing + # but host[:port] — no userinfo, no path, no query. `build_host_header` + # derives it from DESTINATION_BASE_URL. + msg "[$label] Host header sent upstream names the upstream authority" + local want_host stripped seen_host + stripped="${PAIVANA_DEST#*://}" # drop scheme + want_host="${stripped%%/*}" # drop any path + seen_host="$(grep -i '^host:' "$TMPDIR/body" | tr -d '\r' | \ + sed -e 's/^[Hh][Oo][Ss][Tt]: *//')" + [ -n "$seen_host" ] || \ + fail "upstream saw no Host header; headers:\n$(cat "$TMPDIR/body")" + [ "$seen_host" = "$want_host" ] || \ + fail "upstream saw 'Host: $seen_host', want 'Host: $want_host'" + ok + # RFC 9110 §7.6.3: client's Via chain must be preserved and our # pseudonym *appended* to it, not replaced. msg "[$label] client Via is preserved and paivana is appended"