paivana

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

commit a210b2488186a221793a1b6d9a56123951eccf46
parent effddb3a4ab356cee91d07d1dbe54e9037d8300a
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed,  5 Aug 2026 20:00:06 +0200

anchor regexes everywhere

Diffstat:
MREADME | 8++++++++
Msrc/backend/paivana-httpd.c | 22+++++++++++++++++++---
Msrc/backend/paivana-httpd.h | 14++++++++++++++
Msrc/backend/paivana-httpd_daemon.c | 21+++++++++++++++++++++
Msrc/backend/paivana-httpd_templates.c | 38+++++++++++++++++++++++++++++++++++---
Msrc/tests/early_response_upstream.c | 66+++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/tests/meson.build | 1+
7 files changed, 147 insertions(+), 23 deletions(-)

diff --git a/README b/README @@ -16,6 +16,9 @@ How it works 0. `paivana-httpd` learns prices from the Paivana templates configured in the taler-merchant-backend. Paivana templates include a regular expression which determines the set of pages the template applies to. + That expression (POSIX extended) is matched against the *entire* + URL and is anchored at both ends, so `/premium/` applies to nothing + while `.*/premium/.*` applies to every URL containing it. 1. An HTTP client accesses a page at `paivana-httpd`. 2. If the paywall is enabled for the respective URL and no valid access cookie is present, `paivana-httpd` redirects the browser to @@ -94,6 +97,11 @@ Paivana reads an INI-style `.conf` file. The only section used is (X-Forwarded-Host / Host / X-Forwarded-Port) if absent. SECRET Stable secret for cookie MAC and Paivana ID derivation. A random nonce is generated on every startup if absent. + WHITELIST POSIX extended regular expression; matching request paths + are forwarded without payment. Matched against the + *entire* path and anchored at both ends, so `/free/` + whitelists nothing while `/free/.*` whitelists that + subtree. TRUSTED_PROXIES IPv4 networks whose members are reverse proxies trusted to report the client address in `X-Forwarded-For`. Only diff --git a/src/backend/paivana-httpd.c b/src/backend/paivana-httpd.c @@ -386,9 +386,25 @@ run (void *cls, "WHITELIST", &whitelist)) { - if (0 != regcomp (&PH_whitelist_ex, - whitelist, - REG_NOSUB | REG_EXTENDED)) + char *anchored; + int rc; + + /* Anchor the expression: regexec(3) is unanchored, so a + WHITELIST of "/free/" would otherwise waive payment for every + URL merely *containing* it -- including one an attacker + appends to a path they want for free. Wrapping in a group + keeps alternations ("a|b") from binding the anchors to only + the first and last branch. An expression that already + anchors itself is unaffected, as ^ and $ inside still match + at string start/end. */ + GNUNET_asprintf (&anchored, + "^(%s)$", + whitelist); + rc = regcomp (&PH_whitelist_ex, + anchored, + REG_NOSUB | REG_EXTENDED); + GNUNET_free (anchored); + if (0 != rc) { GNUNET_log_config_invalid (GNUNET_ERROR_TYPE_ERROR, "paivana", diff --git a/src/backend/paivana-httpd.h b/src/backend/paivana-httpd.h @@ -42,6 +42,20 @@ #define PH_HEADER_X_FORWARDED_HOST "X-Forwarded-Host" #define PH_HEADER_X_FORWARDED_PORT "X-Forwarded-Port" +/** + * Longest URL we are willing to run a regular expression over. + * + * Both the WHITELIST expression and the ones from the merchant's + * templates are matched against client-controlled URLs on the + * pre-payment path, and neither is validated beyond regcomp(3) + * succeeding. A careless expression can exhibit catastrophic + * backtracking, whose cost then grows super-linearly in the length of + * the subject; capping the subject length bounds that cost. 16 kb is + * far above any legitimate URL and at (or above) the request line + * limit of the usual front-end servers. + */ +#define PH_MAX_URL_LENGTH (16 * 1024) + #define PAIVANA_LOG_INFO(...) \ GNUNET_log (GNUNET_ERROR_TYPE_INFO, __VA_ARGS__) #define PAIVANA_LOG_DEBUG(...) \ diff --git a/src/backend/paivana-httpd_daemon.c b/src/backend/paivana-httpd_daemon.c @@ -152,6 +152,27 @@ create_response (void *cls, upload_data_size); } + if ( (! rc->do_forward) && + (PH_MAX_URL_LENGTH < strlen (url)) ) + { + /* Refuse before either regexec() -- the whitelist just below and + the templates' expressions further down both run on the + pre-payment path with a client-controlled subject. Refusing + outright (rather than skipping the whitelist) also keeps this + from being read as "not whitelisted, so charge for it": there + is nothing to charge for at this length. Requests already + destined to be forwarded reach no expression at all, and are + left alone -- notably every request under -n, where paivana is + a plain reverse proxy. */ + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Refusing %llu byte URL\n", + (unsigned long long) strlen (url)); + return TALER_MHD_reply_with_error (rc->connection, + MHD_HTTP_URI_TOO_LONG, + TALER_EC_GENERIC_URI_TOO_LONG, + NULL); + } + if (PH_have_whitelist_ex && (! rc->do_forward)) { rc->do_forward = (0 == diff --git a/src/backend/paivana-httpd_templates.c b/src/backend/paivana-httpd_templates.c @@ -473,9 +473,24 @@ parse_template (struct Template *t, } if (NULL != regex) { - if (0 != regcomp (&t->ex, - regex, - REG_NOSUB | REG_EXTENDED)) + char *anchored; + int rc; + + /* Anchor the merchant's expression: regexec(3) is unanchored, so + an expression like "/premium/" would otherwise put a paywall on + every URL merely *containing* it. Wrapping in a group keeps + alternations ("a|b") from binding the anchors to only the first + and last branch. An expression that already anchors itself is + unaffected, as ^ and $ inside still match at string + start/end. */ + GNUNET_asprintf (&anchored, + "^(%s)$", + regex); + rc = regcomp (&t->ex, + anchored, + REG_NOSUB | REG_EXTENDED); + GNUNET_free (anchored); + if (0 != rc) { GNUNET_break_op (0); GNUNET_log (GNUNET_ERROR_TYPE_ERROR, @@ -633,6 +648,23 @@ PAIVANA_HTTPD_search_templates (struct MHD_Connection *connection, GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, "Searching templates for `%s'\n", website); + if (PH_MAX_URL_LENGTH < strlen (website)) + { + enum MHD_Result ret; + + /* Refuse rather than returning #GNUNET_SYSERR: that would mean + "no paywall applies" and hand the request to the upstream for + free. */ + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Refusing to match templates against %llu byte URL\n", + (unsigned long long) strlen (website)); + ret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_URI_TOO_LONG, + TALER_EC_GENERIC_URI_TOO_LONG, + NULL); + return (MHD_YES == ret) ? GNUNET_OK : GNUNET_NO; + } for (struct Template *t = t_head; NULL != t; t = t->next) { struct MHD_Response *redirect; diff --git a/src/tests/early_response_upstream.c b/src/tests/early_response_upstream.c @@ -51,19 +51,10 @@ without ever reading from the socket */ #define _GNU_SOURCE #endif -#include <errno.h> -#include <netinet/in.h> +#include "platform.h" +#include <gnunet/gnunet_util_lib.h> #include <poll.h> -#include <signal.h> -#include <stdbool.h> -#include <stdint.h> -#include <stdio.h> -#include <stdlib.h> -#include <string.h> #include <strings.h> -#include <sys/socket.h> -#include <sys/types.h> -#include <unistd.h> /** * How long (ms) a `--no-drain` connection is parked before we give up @@ -396,6 +387,37 @@ handle_client (int fd) } +/** + * Parse @a arg as a TCP port number. Rejects trailing garbage and + * anything outside 1-65535; port 0 in particular is refused, as we + * would have no way to tell the caller which port the kernel picked. + * + * @param arg command-line argument to parse + * @param[out] port set to the parsed port on success + * @return 0 on success, -1 if @a arg is not a valid port + */ +static int +parse_port (const char *arg, + int *port) +{ + char *end; + long long v; + + errno = 0; + v = strtoll (arg, + &end, + 10); + if ( (0 != errno) || + (end == arg) || + ('\0' != *end) || + (v < 1) || + (v > 65535) ) + return -1; + *port = (int) v; + return 0; +} + + int main (int argc, char **argv) @@ -416,7 +438,14 @@ main (int argc, } if (! have_port) { - port = atoi (argv[i]); + if (0 != parse_port (argv[i], + &port)) + { + fprintf (stderr, + "invalid port `%s'\n", + argv[i]); + return 1; + } have_port = 1; continue; } @@ -461,11 +490,14 @@ main (int argc, perror ("socket"); return 1; } - setsockopt (srv, - SOL_SOCKET, - SO_REUSEADDR, - &yes, - sizeof (yes)); + /* Only fails on a bad fd/level/optname, i.e. never here; the + assertion is to keep a silent failure from turning into a + spurious EADDRINUSE two test cases later. */ + GNUNET_assert (0 == setsockopt (srv, + SOL_SOCKET, + SO_REUSEADDR, + &yes, + sizeof (yes))); if (! drain_body) { int rcvbuf = NO_DRAIN_RCVBUF; diff --git a/src/tests/meson.build b/src/tests/meson.build @@ -22,6 +22,7 @@ pipeline_client = executable( early_response_upstream = executable( 'early_response_upstream', 'early_response_upstream.c', + dependencies: [gnunetutil_dep], include_directories: [incdir, configuration_inc], install: false, )