commit a210b2488186a221793a1b6d9a56123951eccf46
parent effddb3a4ab356cee91d07d1dbe54e9037d8300a
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 5 Aug 2026 20:00:06 +0200
anchor regexes everywhere
Diffstat:
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,
)