paivana

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

commit 2d314ea1199dd8310984ddbc28f8704cba8ada67
parent a8b943313330695fb48a8ebb271784a64cbeaba8
Author: Christian Grothoff <christian@grothoff.org>
Date:   Fri,  7 Aug 2026 14:03:33 +0200

clean up Debian package and especially hardening

Diffstat:
MREADME | 30++++++++++++++++++++++++------
Mdebian/control | 2--
Ddebian/etc/apache2/sites-available/paivana.conf | 37-------------------------------------
Ddebian/etc/nginx/sites-available/paivana | 47-----------------------------------------------
Mdebian/etc/paivana/paivana.conf | 12+++++-------
Mdebian/paivana-httpd.postinst | 65+++++++++++++++++++++++++++++++++++++++++------------------------
Mdebian/paivana-httpd.postrm | 14+++++++++-----
Mdebian/paivana-httpd.service | 28+++++++++++++++++++++++++++-
Mdebian/rules | 7++++++-
Msrc/backend/paivana-httpd.c | 27++++++++++++++++++++++++---
10 files changed, 136 insertions(+), 133 deletions(-)

diff --git a/README b/README @@ -44,8 +44,16 @@ How it works via libcurl and the response is streamed back. The cookie is an HMAC over `(expiration time, website, client address)` keyed by a -`paivana_secret` derived from the configured `SECRET`. If `SECRET` is absent, -a random nonce is used and cookies do not survive a restart of `paivana-httpd` +`paivana_secret` derived from the configured `SECRET`. `SECRET` is +required whenever the paywall is on: it is the only input to that MAC +the client does not supply, so a key invented afresh at each start would +invalidate every access anyone had already paid for. Under `-n` no +cookie is ever minted and none is needed. + +Keep the same `SECRET` on every `paivana-httpd` serving one site — a +cookie issued by one has to verify on the next — and treat it as +equivalent to the content itself: whoever holds it can mint access for +any visitor, URL and expiry. Dependencies @@ -96,13 +104,17 @@ Paivana reads an INI-style `.conf` file. The only section used is BASE_URL Public base URL of Paivana. Required unless `-f` is given, in which case it is derived from the forwarding headers (see below). + SECRET Key for the access-cookie MAC and the Paivana ID + derivation. Required unless `-n`; see above for + why there is no sensible default. The Debian + package generates one into + /etc/paivana/secrets/paivana.secret.conf, which + is not world-readable. ### Optional keys Key Description -------- --------------------------------------------------------------- - 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/` @@ -243,10 +255,16 @@ Apache (requires mod_proxy, mod_proxy_http and mod_headers): RequestHeader set Forwarded \ "for=%{REMOTE_ADDR}e;proto=%{REQUEST_SCHEME}e;host=%{HTTP_HOST}e" - ProxyPass "unix:/var/lib/paivana/httpd/paivana.sock|http://example.com/" + ProxyPass "unix:/run/paivana/httpd/paivana-http.sock|http://example.com/" </Location> -Ready-made versions of both are shipped in `debian/etc/`. +Ready-made versions of both are shipped in `debian/examples/`, and +installed by the Debian package into +`/usr/share/doc/paivana-httpd/examples/`. They are examples rather +than drop-ins on purpose: the package only *recommends* a web server, +so it must not create `/etc/nginx/` or `/etc/apache2/` on a system +that has neither (Debian Policy 9.1.1). Copy the one you want into +place and enable it yourself. Trusted proxies diff --git a/debian/control b/debian/control @@ -30,9 +30,7 @@ Pre-Depends: Depends: libtalerexchange (>= 1.6.10), libtalermerchant (>= 1.6.0), - libgnutls30 (>= 3.7.1), adduser, - lsb-base, netbase, ${misc:Depends}, ${shlibs:Depends} diff --git a/debian/etc/apache2/sites-available/paivana.conf b/debian/etc/apache2/sites-available/paivana.conf @@ -1,37 +0,0 @@ -<!-- - Make sure to enable the following Apache modules before - integrating this into your configuration: - - # a2enmod proxy - # a2enmod proxy_http - # a2enmod headers ---> - -<Location "/"> -# paivana-httpd is started with -f (see paivana-httpd.service), so it -# takes the client address for the access cookie from the forwarding -# headers. mod_proxy's ProxyAddHeaders (on by default) *appends* the -# real client to any X-Forwarded-For the client itself sent, which -# would leave the client in control of the leftmost entry -- and thus -# of its own identity. Drop the client's copies first so that what -# mod_proxy adds is the only thing paivana-httpd sees. -# -# If this Apache is itself behind another proxy, remove these and -# configure mod_remoteip (RemoteIPHeader / RemoteIPInternalProxy) for -# that hop instead. -RequestHeader unset X-Forwarded-For -RequestHeader unset X-Forwarded-Proto -RequestHeader unset X-Forwarded-Host -RequestHeader unset X-Forwarded-Port -RequestHeader unset Forwarded - -# RFC 7239, which paivana-httpd prefers over the X-Forwarded-* headers -# mod_proxy adds. Apache emits no Forwarded of its own, so build the -# element here. "set" rather than "append": this is the outermost hop, -# so a client-supplied element must not survive. Note that -# %{REMOTE_ADDR}e yields an unbracketed IPv6 address where RFC 7239 §6 -# asks for for="[...]"; paivana-httpd accepts both. -RequestHeader set Forwarded "for=%{REMOTE_ADDR}e;proto=%{REQUEST_SCHEME}e;host=%{HTTP_HOST}e" - -ProxyPass "unix:/var/lib/paivana/httpd/paivana.sock|http://example.com/" -</Location> diff --git a/debian/etc/nginx/sites-available/paivana b/debian/etc/nginx/sites-available/paivana @@ -1,47 +0,0 @@ -# RFC 7239 node identifier for the peer we accepted from. nginx has -# no built-in variable for this: an IPv6 address has to be bracketed -# and therefore quoted (RFC 7239 §6), and a peer with no address is -# "unknown" (§6.3). -map $remote_addr $paivana_forwarded_elem { - ~^[0-9.]+$ "for=$remote_addr"; - ~^[0-9A-Fa-f:.]+$ "for=\"[$remote_addr]\""; - default "for=unknown"; -} - -server { - listen 80; - listen [::]:80; - - # server_name example.com - - location / { - proxy_pass http://unix:/run/paivana/httpd/paivana-http.sock; - proxy_redirect off; - proxy_set_header Host $host; - - # paivana-httpd is started with -f (see paivana-httpd.service), so - # it takes the client address for the access cookie from the - # headers set here. That is only sound because this server is the - # outermost hop and *overwrites* them: $remote_addr is the peer we - # actually accepted, whereas $proxy_add_x_forwarded_for would - # append it to whatever the client claimed, leaving the client in - # control of the leftmost entry -- and thus of its own identity. - # - # If this nginx is itself behind another proxy, switch to - # $proxy_add_x_forwarded_for and set real_ip_header / - # set_real_ip_from for that hop. - proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - - # RFC 7239. paivana-httpd prefers this over the X-Forwarded-* - # headers above, which are kept for origins that only speak those. - # Again a plain "set": nginx offers no $proxy_add_forwarded, and at - # the outermost hop we would not want one -- a client-supplied - # element must not survive. Behind another proxy, replace this - # with the appending form from nginx.org's "Using the Forwarded - # header", which validates $http_forwarded before extending it. - proxy_set_header Forwarded "$paivana_forwarded_elem;proto=$scheme;host=$host"; - } -} diff --git a/debian/etc/paivana/paivana.conf b/debian/etc/paivana/paivana.conf @@ -4,16 +4,10 @@ # after having already been handed the activated socket. SERVE = systemd -# The three settings below have no default and paivana-httpd refuses to +# The two settings below have no default and paivana-httpd refuses to # start without them. # DESTINATION_BASE_URL = http://localhost:8080/ # MERCHANT_BACKEND_URL = http://localhost:9966/ -# MERCHANT_ACCESS_TOKEN = secret-token:CHANGE-ME - -# Key for the access cookie's MAC. Without it a fresh random key is -# generated at every start, so every restart invalidates every access -# a customer has paid for. -# SECRET = CHANGE-ME # Which HTTP port does the backend listen on? Only used if "SERVE" is 'tcp'. # PORT = 9967 @@ -23,3 +17,7 @@ SERVE = systemd # if left empty. Only used if "SERVE" is 'tcp'. # BIND_TO = +# MERCHANT_ACCESS_TOKEN and SECRET live in the file below rather than +# here, because this file is world-readable and those two are not +# things every local account should be able to read. Keep it that way. +@inline-secret@ paivana secrets/paivana.secret.conf diff --git a/debian/paivana-httpd.postinst b/debian/paivana-httpd.postinst @@ -1,35 +1,52 @@ -#!/bin/bash +#!/bin/sh set -e -if [ -d /run/systemd/system ]; then - systemctl --system daemon-reload >/dev/null || true -fi -if [ "$1" = "remove" ]; then - if [ -x "/usr/bin/deb-systemd-helper" ]; then - deb-systemd-helper mask 'paivana-httpd.service' >/dev/null || true - fi -fi - -if [ "$1" = "purge" ]; then - if [ -x "/usr/bin/deb-systemd-helper" ]; then - deb-systemd-helper purge 'paivana-httpd.service' >/dev/null || true - deb-systemd-helper unmask 'paivana-httpd.service' >/dev/null || true - fi -fi - -PAIVANA_HOME="/var/lib/paivana/" - -. /usr/share/debconf/confmodule +# paivana-httpd keeps no state of its own -- no database, no cache, no +# spool -- so it has no home to create. The account used to name +# /var/lib/paivana/ with --no-create-home, and postrm purged a third +# path again (/var/lib/paivana/httpd/); none of the three ever existed. +PAIVANA_HOME="/nonexistent" +SECRET_CONF="/etc/paivana/secrets/paivana.secret.conf" case "${1}" in configure) - # Creating taler users if needed - if ! getent passwd paivana-httpd >/dev/null; - then - adduser --quiet --system --ingroup www-data --no-create-home --home ${PAIVANA_HOME} paivana-httpd + if ! getent passwd paivana-httpd >/dev/null; + then + adduser --quiet --system --ingroup www-data --no-create-home \ + --home ${PAIVANA_HOME} paivana-httpd fi + # The secrets file carries the merchant bearer token and the key for + # the access-cookie MAC. Either is enough to take the instance over: + # the token is authority over its orders and templates, and the key + # lets an attacker mint access cookies for any visitor. Neither + # belongs in a 0644 file that every local account -- a compromised + # www-data process included -- can read. Same treatment the merchant + # package gives its own secrets. + if ! dpkg-statoverride --list ${SECRET_CONF} >/dev/null 2>&1 + then + dpkg-statoverride \ + --add \ + --update \ + paivana-httpd root 0460 \ + ${SECRET_CONF} + fi + + # Generate the cookie key on first install. paivana-httpd refuses to + # start without one, and leaving the operator to invent it invites + # either a weak value or a per-start random key that logs everyone out + # on every restart. Only ever written when the placeholder is still + # in place, so an upgrade never disturbs a configured system -- and + # never regenerates, which would invalidate every outstanding cookie. + if [ -f ${SECRET_CONF} ] && grep -q '^# SECRET = CHANGE-ME' ${SECRET_CONF}; + then + SECRET="$(head -c 32 /dev/urandom | base64 | tr -d '\n')" + sed -i "s|^# SECRET = CHANGE-ME|SECRET = ${SECRET}|" ${SECRET_CONF} + SECRET="" + echo "paivana-httpd: generated a random SECRET in ${SECRET_CONF}." >&2 + echo "paivana-httpd: copy it to every other host serving this site." >&2 + fi ;; abort-upgrade | abort-remove | abort-deconfigure) ;; diff --git a/debian/paivana-httpd.postrm b/debian/paivana-httpd.postrm @@ -2,14 +2,18 @@ set -e -if [ -f /usr/share/debconf/confmodule ]; then - . /usr/share/debconf/confmodule -fi +SECRET_CONF="/etc/paivana/secrets/paivana.secret.conf" case "${1}" in purge) - rm -rf /var/lib/paivana/httpd/ - rm -f /etc/paivana/override.conf + # Added by postinst; dpkg does not clean up statoverrides itself, and + # leaving one behind makes a later reinstall inherit a mode nothing + # in the package accounts for. + if dpkg-statoverride --list ${SECRET_CONF} >/dev/null 2>&1 + then + dpkg-statoverride --remove ${SECRET_CONF} + fi + rm -f ${SECRET_CONF} deluser --system --quiet paivana-httpd || true ;; remove | upgrade | failed-upgrade | abort-install | abort-upgrade | disappear) diff --git a/debian/paivana-httpd.service b/debian/paivana-httpd.service @@ -31,6 +31,11 @@ RestartMaxDelaySec=300s # unparseable TRUSTED_PROXIES only fills the journal. RestartPreventExitStatus=6 9 +# Recycle hourly. This is only tolerable because `SECRET' is now +# mandatory (paivana-httpd exits 6 without it) and the package +# generates one: with a per-start random key, every restart would +# invalidate every access cookie, so an hourly restart meant a customer +# paying at 10:59 was shown the paywall again at 11:01. RuntimeMaxSec=3600s # -f: we are served over a Unix socket by nginx/Apache (see the # shipped site configs), so the client address has to come from the @@ -43,9 +48,30 @@ ExecStart=/usr/bin/paivana-httpd -c /etc/paivana/paivana.conf -f -L INFO StandardOutput=journal StandardError=journal + +# Hardening. paivana-httpd needs a listening socket handed to it, +# outbound TCP to the merchant backend and the origin, and read access +# to its own configuration -- nothing else. DynamicUser is deliberately +# NOT used: paivana-httpd.socket names SocketUser=paivana-httpd, which +# needs a stable account. PrivateTmp=yes PrivateDevices=yes -ProtectSystem=full +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +NoNewPrivileges=yes +RestrictSUIDSGID=yes +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RestrictRealtime=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +CapabilityBoundingSet= +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM [Install] WantedBy=multi-user.target diff --git a/debian/rules b/debian/rules @@ -15,7 +15,12 @@ override_dh_auto_configure-indep: override_dh_auto_build-indep: override_dh_auto_test: - # Disabling test suite, incomplete + # The reverse_proxy suite needs a completed `make install' -- it + # resolves paivana-httpd and its templates through the prefix -- and + # starts listeners, neither of which belongs in a build chroot. The + # two unit tests have no such requirement, so run those rather than + # nothing at all. + dh_auto_test -- client_address cookie_header override_dh_auto_install-arch: # paivana-httpd is the only binary package, so dh_auto_install would diff --git a/src/backend/paivana-httpd.c b/src/backend/paivana-httpd.c @@ -624,9 +624,30 @@ run (void *cls, "SECRET", &secret)) { - GNUNET_log_config_missing (GNUNET_ERROR_TYPE_WARNING, - "paivana", - "SECRET"); + if (! PH_no_check) + { + /* The key is the only input to the access-cookie MAC that the + client does not supply, so a fresh random one invalidates + every access anyone has paid for. That used to be a warning + and a random key, which is survivable only if restarts are + rare -- and the shipped unit restarts hourly (RuntimeMaxSec), + so a customer paying at 10:59 was shown the paywall again at + 11:01. Refuse to start instead: the packaging generates one + at install time, and an operator configuring by hand needs to + be told rather than silently sold a paywall that forgets. `-n' + mints no cookies at all and so needs no key. */ + GNUNET_log_config_missing (GNUNET_ERROR_TYPE_ERROR, + "paivana", + "SECRET"); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Refusing to start without `SECRET': every restart" + " would invalidate all access already paid for." + " Generate one with `gpg --gen-random 0 32 | base64'" + " (or pass -n to serve without a paywall).\n"); + PH_global_ret = EXIT_NOTCONFIGURED; + GNUNET_SCHEDULER_shutdown (); + return; + } GNUNET_CRYPTO_random_block (&paivana_secret, sizeof (paivana_secret)); }