paivana

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

commit 44abe13cc8bc06fe657ac9a66b5759d22ffbf7a8
parent b696a70c08882b6c33c43450407f4c3e4732807e
Author: Christian Grothoff <christian@grothoff.org>
Date:   Tue,  4 Aug 2026 18:26:10 +0200

make forwarding headers follow --respect-forwarded-headers

append_forwarded_headers() always derived X-Forwarded-For from the raw
socket peer and con_val_iter() dropped every inbound X-Forwarded-*, so
behind a trusted proxy -- the documented purpose of -f -- the upstream
was told the proxy was the client and the original chain was lost.
Meanwhile the access cookie's client address *did* honor -f, so the
address we authenticated and the address we forwarded disagreed.

Capture the inbound X-Forwarded-{For,Proto,Host} in collect_proxy_state()
and rebuild them in append_forwarded_headers(), the same way Via is
already handled.  With -f the chain is extended with our own peer
rather than replaced; without it we are the outermost proxy and a
client's assertions are still discarded.

A Unix-domain peer yields no address: the chain is forwarded unadorned
rather than gaining an invented "127.0.0.1", which would be
unverifiable and indistinguishable from a real loopback client.  The
hop is recorded in Via, which needs no address.

Diffstat:
MREADME | 41++++++++++++++++++++++++++++++++++++++++-
Mdebian/etc/apache2/sites-available/paivana.conf | 18++++++++++++++++++
Mdebian/etc/nginx/sites-available/paivana | 20++++++++++++++++++++
Mdebian/paivana-httpd.service | 9++++++++-
Msrc/backend/paivana-httpd.h | 13+++++++++++++
Msrc/backend/paivana-httpd_helper.c | 18+++++++++++++-----
Msrc/backend/paivana-httpd_reverse.c | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/tests/README | 32++++++++++++++++++++++++++++++++
Asrc/tests/test_client_address.c | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/tests/test_reverse_proxy.sh | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/test_reverse_proxy_unix.conf.in | 18++++++++++++++++++
11 files changed, 1010 insertions(+), 59 deletions(-)

diff --git a/README b/README @@ -119,6 +119,26 @@ Deployment behind a reverse proxy The recommended production setup runs Paivana over a Unix socket and places nginx or Apache in front for TLS termination. +In that setup Paivana **must** be started with `-f` / +`--respect-forwarded-headers`. A Unix-domain peer has no address of +its own, so without `-f` there is no client address at all: the access +cookie cannot be bound to a client, and `POST /.well-known/paivana` +fails. `-f` makes Paivana take the client address from +`X-Forwarded-For` instead, and forward the chain it was given to the +upstream rather than replacing it. + +`-f` is only safe if the server in front **overwrites** the forwarding +headers rather than appending to whatever the client sent. Otherwise a +client can put any address it likes in the leftmost position and so +choose the identity its access cookie is bound to. The configurations +below do this; if you write your own, note that nginx does not set +`X-Forwarded-For` at all unless told to, and that Apache's +`ProxyAddHeaders` *appends* to a client-supplied value. + +Conversely, do not pass `-f` to a Paivana that clients can reach +directly — there it is the client, not a proxy, that is setting those +headers. + nginx (`/etc/nginx/sites-available/paivana`): server { @@ -128,15 +148,34 @@ nginx (`/etc/nginx/sites-available/paivana`): location / { proxy_pass http://unix:/run/paivana/httpd/paivana-http.sock; proxy_set_header Host $host; + + # $remote_addr, not $proxy_add_x_forwarded_for: this is + # the outermost hop, so these overwrite rather than + # extend what the client claimed. + 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; + proxy_set_header Forwarded ""; } } -Apache (requires mod_proxy and mod_proxy_http): +Apache (requires mod_proxy, mod_proxy_http and mod_headers): <Location "/"> + # mod_proxy appends the real client to any X-Forwarded-For the + # client itself sent, so drop the client's copies first. + RequestHeader unset X-Forwarded-For + RequestHeader unset X-Forwarded-Proto + RequestHeader unset X-Forwarded-Host + RequestHeader unset X-Forwarded-Port + RequestHeader unset Forwarded + ProxyPass "unix:/var/lib/paivana/httpd/paivana.sock|http://example.com/" </Location> +Ready-made versions of both are shipped in `debian/etc/`. + Set `BASE_URL` in the configuration file to the public HTTPS URL so that redirects and cookie domains are correct. diff --git a/debian/etc/apache2/sites-available/paivana.conf b/debian/etc/apache2/sites-available/paivana.conf @@ -4,8 +4,26 @@ # 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 + 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 @@ -8,5 +8,25 @@ server { 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. Not emitted by default, and a client-supplied one + # must not reach paivana-httpd. + proxy_set_header Forwarded ""; } } diff --git a/debian/paivana-httpd.service b/debian/paivana-httpd.service @@ -19,7 +19,14 @@ StartLimitBurst=5 StartLimitInterval=5s RuntimeMaxSec=3600s -ExecStart=/usr/bin/paivana-httpd -c /etc/paivana/paivana.conf -L INFO +# -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 +# forwarding headers -- a Unix peer has no address of its own, and +# without this every visitor would be indistinguishable. It is only +# safe because those configs overwrite the headers rather than +# appending to a client-supplied value; do not enable it for a +# paivana-httpd that is reachable directly. +ExecStart=/usr/bin/paivana-httpd -c /etc/paivana/paivana.conf -f -L INFO StandardOutput=journal StandardError=journal diff --git a/src/backend/paivana-httpd.h b/src/backend/paivana-httpd.h @@ -30,6 +30,19 @@ #include <regex.h> #include <stdbool.h> +/** + * The de-facto forwarding headers. libmicrohttpd only defines the + * standardized #MHD_HTTP_HEADER_FORWARDED (RFC 7239), but these are + * what nginx, Apache and everything else in front of us actually + * emit. They are consumed on the way in (client address, base URL) + * and produced on the way out (proxying), in different files, so + * they are spelled out once here. + */ +#define PH_HEADER_X_FORWARDED_FOR "X-Forwarded-For" +#define PH_HEADER_X_FORWARDED_PROTO "X-Forwarded-Proto" +#define PH_HEADER_X_FORWARDED_HOST "X-Forwarded-Host" +#define PH_HEADER_X_FORWARDED_PORT "X-Forwarded-Port" + #define PAIVANA_LOG_INFO(...) \ GNUNET_log (GNUNET_ERROR_TYPE_INFO, __VA_ARGS__) #define PAIVANA_LOG_DEBUG(...) \ diff --git a/src/backend/paivana-httpd_helper.c b/src/backend/paivana-httpd_helper.c @@ -151,7 +151,7 @@ PAIVANA_HTTPD_get_client_address (struct MHD_Connection *connection, xff = MHD_lookup_connection_value (connection, MHD_HEADER_KIND, - "X-Forwarded-For"); + PH_HEADER_X_FORWARDED_FOR); if (NULL != xff) return PAIVANA_HTTPD_parse_forwarded_for (xff, ca, @@ -199,8 +199,16 @@ PAIVANA_HTTPD_get_base_url (struct MHD_Connection *connection, PH_base_url); return true; } - is_https = (GNUNET_YES == - TALER_mhd_is_https (connection)); + /* TALER_mhd_is_https() reports https "either directly or via + proxy", i.e. it also believes an inbound X-Forwarded-Proto. That + is only a truthful answer when something trusted set that header; + otherwise any client could decide the scheme of the URLs we + generate for it. Unless we are behind a trusted proxy, go by the + transport: MHD reports a TLS session or it does not. */ + is_https = PH_respect_forwarded_headers + ? (GNUNET_YES == TALER_mhd_is_https (connection)) + : (NULL != MHD_get_connection_info (connection, + MHD_CONNECTION_INFO_PROTOCOL)); if (is_https) GNUNET_buffer_write_str (buf, "https://"); @@ -210,7 +218,7 @@ PAIVANA_HTTPD_get_base_url (struct MHD_Connection *connection, forwarded_host = PH_respect_forwarded_headers ? MHD_lookup_connection_value (connection, MHD_HEADER_KIND, - "X-Forwarded-Host") + PH_HEADER_X_FORWARDED_HOST) : NULL; if (NULL != forwarded_host) { @@ -221,7 +229,7 @@ PAIVANA_HTTPD_get_base_url (struct MHD_Connection *connection, /* Only reached when PH_respect_forwarded_headers is set. */ forwarded_port = MHD_lookup_connection_value (connection, MHD_HEADER_KIND, - "X-Forwarded-Port"); + PH_HEADER_X_FORWARDED_PORT); if ( (NULL != forwarded_port) && (0 != strcmp (forwarded_port, is_https ? "443" : "80") ) ) diff --git a/src/backend/paivana-httpd_reverse.c b/src/backend/paivana-httpd_reverse.c @@ -269,6 +269,34 @@ struct HttpRequest char *client_via; /** + * Concatenated value of the client's `X-Forwarded-For` header(s), + * if any. Only meaningful when #PH_respect_forwarded_headers is + * set, in which case our own entry is *appended* to this chain + * rather than replacing it: the origin needs the whole path a + * request took, and the client address it names has to agree with + * the one the access cookie was keyed on. Captured here (like + * @e client_via) because `con_val_iter` drops the raw header and + * `append_forwarded_headers` re-emits a single combined one. + */ + char *client_xff; + + /** + * Value of the client's `X-Forwarded-Proto` header, if any. Used + * in place of the scheme of our own listener when + * #PH_respect_forwarded_headers is set. Not a list header: the + * first value wins. + */ + char *client_xfp; + + /** + * Value of the client's `X-Forwarded-Host` header, if any. Used + * in place of the client's `Host:` when + * #PH_respect_forwarded_headers is set. Not a list header: the + * first value wins. + */ + char *client_xfh; + + /** * Concatenated value of the client's Connection header(s), if * any. Per RFC 9110 §7.6.1 this lists additional header names * that are connection-specific and must not be forwarded; we @@ -557,12 +585,20 @@ append_list_value (char **target, /** - * Pre-iteration collector: records the client's Via and Connection - * headers on @a hr so the subsequent forwarding pass can append to - * Via (RFC 9110 §7.6.3) and honor the hop-by-hop names listed in - * Connection (RFC 9110 §7.6.1). Multiple values for the same - * header are joined with ", ", matching the list-header combining - * rule. + * Pre-iteration collector: records the client's Via, Connection and + * X-Forwarded-* headers on @a hr so the subsequent forwarding pass + * can append to Via (RFC 9110 §7.6.3), honor the hop-by-hop names + * listed in Connection (RFC 9110 §7.6.1), and extend rather than + * discard an inbound forwarding chain. `con_val_iter` drops all of + * these from the upstream request; `append_forwarded_headers` + * re-emits them, which is why they have to be captured before that + * pass runs. + * + * Via, Connection and X-Forwarded-For are list headers: multiple + * values are joined with ", " per RFC 9110 §5.3. X-Forwarded-Proto + * and X-Forwarded-Host name a single value each, so a repeat is + * ignored rather than concatenated into something no longer parseable + * as a scheme or an authority. * * @param cls our `struct HttpRequest *` * @param kind value kind (unused) @@ -582,12 +618,29 @@ collect_proxy_state (void *cls, (void) kind; if (NULL == value) return MHD_YES; + if (0 == strcasecmp (PH_HEADER_X_FORWARDED_PROTO, + key)) + { + if (NULL == hr->client_xfp) + hr->client_xfp = GNUNET_strdup (value); + return MHD_YES; + } + if (0 == strcasecmp (PH_HEADER_X_FORWARDED_HOST, + key)) + { + if (NULL == hr->client_xfh) + hr->client_xfh = GNUNET_strdup (value); + return MHD_YES; + } if (0 == strcasecmp (MHD_HTTP_HEADER_VIA, key)) target = &hr->client_via; else if (0 == strcasecmp (MHD_HTTP_HEADER_CONNECTION, key)) target = &hr->client_connection; + else if (0 == strcasecmp (PH_HEADER_X_FORWARDED_FOR, + key)) + target = &hr->client_xff; else return MHD_YES; append_list_value (target, @@ -1165,6 +1218,9 @@ PAIVANA_HTTPD_reverse_cleanup (struct HttpRequest *hr) GNUNET_free (hr->url); GNUNET_free (hr->io_buf); GNUNET_free (hr->client_via); + GNUNET_free (hr->client_xff); + GNUNET_free (hr->client_xfp); + GNUNET_free (hr->client_xfh); GNUNET_free (hr->client_connection); GNUNET_free (hr->upstream_connection); GNUNET_CONTAINER_DLL_remove (hr_head, @@ -1401,11 +1457,108 @@ configure_curl_method (struct HttpRequest *hr, /** + * Format the address of the peer we accepted @a con from, or return + * NULL if it has none to speak of. + * + * A Unix-domain peer deliberately yields NULL: there is no address + * to name, and inventing one ("127.0.0.1") would be both unverifiable + * and indistinguishable from a genuine loopback client. The hop is + * still recorded — in `Via`, which does not require an address. + * + * @param con MHD connection we are processing + * @param[out] buf scratch space to format into + * @param buf_size number of bytes in @a buf + * @return @a buf, or NULL if the peer has no IP address + */ +static const char * +peer_address (struct MHD_Connection *con, + char *buf, + size_t buf_size) +{ + const union MHD_ConnectionInfo *ci; + + ci = MHD_get_connection_info (con, + MHD_CONNECTION_INFO_CLIENT_ADDRESS); + if ( (NULL == ci) || + (NULL == ci->client_addr) ) + return NULL; + switch (ci->client_addr->sa_family) + { + case AF_INET: + return inet_ntop ( + AF_INET, + &((const struct sockaddr_in *) ci->client_addr)->sin_addr, + buf, + buf_size); + case AF_INET6: + return inet_ntop ( + AF_INET6, + &((const struct sockaddr_in6 *) ci->client_addr)->sin6_addr, + buf, + buf_size); + default: + /* AF_UNIX: see above. */ + return NULL; + } +} + + +/** + * Determine the scheme the *client* used to reach us, for + * `X-Forwarded-Proto`. + * + * Without #PH_respect_forwarded_headers this is a property of our own + * listener and nothing else: #TALER_mhd_is_https() is documented as + * reporting https "either directly or via proxy", i.e. it also + * believes an inbound `X-Forwarded-Proto`, which an untrusted client + * can simply assert. So when we are not behind a trusted proxy we + * ask MHD about the transport instead — a TLS session exists or it + * does not. + * + * @param con MHD connection we are processing + * @param client_xfp inbound `X-Forwarded-Proto`, or NULL + * @return "https" or "http" + */ +static const char * +forwarded_proto (struct MHD_Connection *con, + const char *client_xfp) +{ + if (PH_respect_forwarded_headers) + { + if (NULL != client_xfp) + return client_xfp; + return (GNUNET_YES == TALER_mhd_is_https (con)) + ? "https" : "http"; + } + return (NULL != MHD_get_connection_info (con, + MHD_CONNECTION_INFO_PROTOCOL)) + ? "https" : "http"; +} + + +/** * Attach the reverse-proxy forwarding headers (X-Forwarded-For / - * -Proto / -Host and Via) to `hr->headers`. Our X-Forwarded-* - * replace any client-supplied values (filtered in `con_val_iter`); - * Via is appended to whatever chain the client already carried, - * per RFC 9110 §7.6.3. + * -Proto / -Host and Via) to `hr->headers`. + * + * Which of the two roles paivana plays here is decided by + * #PH_respect_forwarded_headers, the same flag that decides where the + * access cookie's client address comes from — the two must agree, or + * the origin is told one thing about a request while we authenticated + * another: + * + * - Without the flag we are the outermost proxy. Any inbound + * `X-Forwarded-*` is a client assertion and is replaced with what we + * can see for ourselves, so that a client cannot dictate what the + * origin believes about it. + * - With the flag we are behind a trusted proxy, and the values it + * sent are the truthful ones. We *extend* the chain with the peer + * we accepted from rather than discarding it, which is the whole + * point of the header (and what RFC 9110 §7.6.3 requires of `Via` + * in any case). + * + * `Forwarded` (RFC 7239) is dropped in `con_val_iter` either way: we + * do not yet emit one, and relaying an inbound one unchanged would + * leave it contradicting the `X-Forwarded-For` we do extend. * * @param[in,out] hr the request * @param con MHD connection we are processing @@ -1416,62 +1569,64 @@ append_forwarded_headers (struct HttpRequest *hr, struct MHD_Connection *con, const char *ver) { - const union MHD_ConnectionInfo *ci; + char ipbuf[INET6_ADDRSTRLEN]; char *hdr; - const char *proto; + const char *peer; const char *fhost; const char *via_ver = "1.1"; - ci = MHD_get_connection_info (con, - MHD_CONNECTION_INFO_CLIENT_ADDRESS); - if ( (NULL != ci) && - (NULL != ci->client_addr) ) + peer = peer_address (con, + ipbuf, + sizeof (ipbuf)); + if ( (PH_respect_forwarded_headers) && + (NULL != hr->client_xff) ) { - char ipbuf[INET6_ADDRSTRLEN]; - const char *ip = NULL; - - switch (ci->client_addr->sa_family) - { - case AF_INET: - ip = inet_ntop ( - AF_INET, - &((const struct sockaddr_in *) ci->client_addr)->sin_addr, - ipbuf, sizeof (ipbuf)); - break; - case AF_INET6: - ip = inet_ntop ( - AF_INET6, - &((const struct sockaddr_in6 *) ci->client_addr)->sin6_addr, - ipbuf, sizeof (ipbuf)); - break; - default: - break; - } - if (NULL != ip) - { + /* Extend the trusted chain. If our own peer has no address + (Unix socket) the chain is passed on as it stands. */ + if (NULL != peer) GNUNET_asprintf (&hdr, - "X-Forwarded-For: %s", - ip); - hr->headers = curl_slist_append (hr->headers, - hdr); - GNUNET_free (hdr); - } + "%s: %s, %s", + PH_HEADER_X_FORWARDED_FOR, + hr->client_xff, + peer); + else + GNUNET_asprintf (&hdr, + "%s: %s", + PH_HEADER_X_FORWARDED_FOR, + hr->client_xff); + hr->headers = curl_slist_append (hr->headers, + hdr); + GNUNET_free (hdr); + } + else if (NULL != peer) + { + GNUNET_asprintf (&hdr, + "%s: %s", + PH_HEADER_X_FORWARDED_FOR, + peer); + hr->headers = curl_slist_append (hr->headers, + hdr); + GNUNET_free (hdr); } - proto = (GNUNET_YES == TALER_mhd_is_https (con)) - ? "https" : "http"; GNUNET_asprintf (&hdr, - "X-Forwarded-Proto: %s", - proto); + "%s: %s", + PH_HEADER_X_FORWARDED_PROTO, + forwarded_proto (con, + hr->client_xfp)); hr->headers = curl_slist_append (hr->headers, hdr); GNUNET_free (hdr); - fhost = MHD_lookup_connection_value (con, - MHD_HEADER_KIND, - MHD_HTTP_HEADER_HOST); + fhost = (PH_respect_forwarded_headers && + (NULL != hr->client_xfh)) + ? hr->client_xfh + : MHD_lookup_connection_value (con, + MHD_HEADER_KIND, + MHD_HTTP_HEADER_HOST); if (NULL != fhost) { GNUNET_asprintf (&hdr, - "X-Forwarded-Host: %s", + "%s: %s", + PH_HEADER_X_FORWARDED_HOST, fhost); hr->headers = curl_slist_append (hr->headers, hdr); diff --git a/src/tests/README b/src/tests/README @@ -108,6 +108,38 @@ Per-upstream battery (`run_battery`): X-Upstream response header upstream response headers survive the round-trip back to the client +Forwarding-header tests (run once): + + no -f a client's own X-Forwarded-For / + -Proto / -Host must not reach the + upstream: paivana is the outermost + proxy and replaces them with what it + can see for itself. In particular the + scheme must come from the transport, + not from a header the client wrote -- + TALER_mhd_is_https() believes + X-Forwarded-Proto, so paivana asks MHD + about the TLS session instead. + -f, chain extension with -f paivana is behind a trusted + proxy: the inbound chain is preserved + and paivana's own peer appended to the + right, rather than the chain being + thrown away. Also covers a repeated + X-Forwarded-For arriving as two field + lines (RFC 9110 §5.3: one combined + header must reach the upstream). + -f, trusted -Proto / -Host the values a trusted proxy sent are + passed through unchanged. + unix socket the deployment the Debian packaging + ships. A Unix peer has no address, so + with -f the inbound chain is forwarded + unadorned (nothing is appended, and no + placeholder is invented -- the hop is + recorded in Via), and without -f no + X-Forwarded-For is emitted at all. + This is the only case that reaches the + address-less code paths. + Cross-cutting tests (run once): TRACE method unsupported HTTP verb yields 405 Method diff --git a/src/tests/test_client_address.c b/src/tests/test_client_address.c @@ -0,0 +1,462 @@ +/* + This file is part of GNUnet. + Copyright (C) 2026 Taler Systems SA + + Paivana is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version + 3, or (at your option) any later version. + + Paivana is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty + of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See + the GNU General Public License for more details. + + You should have received a copy of the GNU General Public + License along with Paivana; see the file COPYING. If not, + write to the Free Software Foundation, Inc., 51 Franklin + Street, Fifth Floor, Boston, MA 02110-1301, USA. +*/ + +/** + * @file test_client_address.c + * @brief tests that the client address paivana derives from + * X-Forwarded-For is the same binary value it derives from the + * connection's socket address + * + * The access cookie is an HMAC over (expiration, website, client + * address). If a host yields one byte string when it reaches paivana + * through a proxy and a different one when it reaches paivana + * directly -- or when two proxies spell its address differently -- + * then the cookie it was issued stops verifying, with no error + * anywhere: it just looks unpaid again. So the representation has to + * be canonical, which is what this test pins down. + * + * The integration suite cannot cover this: it runs paivana with -n, + * where the cookie path (and hence the client address) is never + * reached at all. + */ +#include "platform.h" +#include <gnunet/gnunet_util_lib.h> +#include "paivana-httpd_helper.h" +#include "paivana-httpd_cookie.h" + +/** + * Globals that paivana-httpd.c normally defines; the helper and + * cookie compilation units reference them. + */ +int PH_respect_forwarded_headers; +char *PH_base_url; +int PH_global_cookie; + +/** + * Cookie key, normally set up by paivana-httpd.c from the SECRET + * configuration value. + */ +extern struct GNUNET_HashCode paivana_secret; + +/** + * Website the test cookies are minted for. + */ +#define WEBSITE "http://example.com/page" + +/** + * Number of checks that did not hold. + */ +static unsigned int failures; + + +/** + * Print @a len bytes of @a p as hex into @a out. + * + * @param p bytes to render + * @param len number of bytes in @a p + * @param[out] out buffer of at least 2 * @a len + 1 bytes + */ +static void +tohex (const void *p, + size_t len, + char *out) +{ + const unsigned char *b = p; + + for (size_t i = 0; i < len; i++) + sprintf (&out[2 * i], + "%02x", + b[i]); + out[2 * len] = '\0'; +} + + +/** + * The binary address the socket branch of + * #PAIVANA_HTTPD_get_client_address() would produce for a peer whose + * address is @a literal. Mirrors that branch rather than calling it, + * as calling it would need a live MHD connection. + * + * @param literal address of the peer, in presentation form + * @param[out] ca where to write the allocated address + * @param[out] ca_len set to the number of bytes in @a ca + */ +static void +socket_address (const char *literal, + void **ca, + size_t *ca_len) +{ + struct in_addr a4; + struct in6_addr a6; + + if (1 == inet_pton (AF_INET, + literal, + &a4)) + { + *ca = GNUNET_memdup (&a4, + sizeof (a4)); + *ca_len = sizeof (a4); + return; + } + GNUNET_assert (1 == inet_pton (AF_INET6, + literal, + &a6)); + if (IN6_IS_ADDR_V4MAPPED (&a6)) + { + *ca = GNUNET_memdup (&a6.s6_addr[12], + sizeof (struct in_addr)); + *ca_len = sizeof (struct in_addr); + return; + } + *ca = GNUNET_memdup (&a6, + sizeof (a6)); + *ca_len = sizeof (a6); +} + + +/** + * Mint an access cookie for @a mint_ca and check it against + * @a check_ca. + * + * @param mint_ca client address the cookie is issued for + * @param mint_len number of bytes in @a mint_ca + * @param check_ca client address presented on the later request + * @param check_len number of bytes in @a check_ca + * @return true if the cookie verified + */ +static bool +cookie_survives (const void *mint_ca, + size_t mint_len, + const void *check_ca, + size_t check_len) +{ + struct GNUNET_TIME_Timestamp ts; + char *setcookie; + char *val; + char *semi; + bool ok; + + ts = GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS); + setcookie = PAIVANA_HTTPD_compute_cookie (ts, + WEBSITE, + mint_len, + mint_ca); + /* Reduce the Set-Cookie line to the bare cookie value, which is + what check_cookie() is given on the next request. */ + val = strchr (setcookie, + '='); + GNUNET_assert (NULL != val); + val++; + semi = strchr (val, + ';'); + if (NULL != semi) + *semi = '\0'; + ok = PAIVANA_HTTPD_check_cookie (val, + WEBSITE, + check_len, + check_ca); + GNUNET_free (setcookie); + return ok; +} + + +/** + * Check that @a xff yields exactly the address a direct connection + * from @a peer would, and that a cookie issued on one path is + * accepted on the other. + * + * @param xff `X-Forwarded-For` value the proxied request carries + * @param peer socket address of the same host connecting directly + */ +static void +same_host (const char *xff, + const char *peer) +{ + void *fa; + void *sa; + size_t fa_len; + size_t sa_len; + char fhex[2 * sizeof (struct in6_addr) + 1]; + char shex[2 * sizeof (struct in6_addr) + 1]; + + socket_address (peer, + &sa, + &sa_len); + if (! PAIVANA_HTTPD_parse_forwarded_for (xff, + &fa, + &fa_len)) + { + fprintf (stderr, + "FAIL: X-Forwarded-For `%s' rejected, want the address of %s\n", + xff, + peer); + failures++; + GNUNET_free (sa); + return; + } + tohex (fa, + fa_len, + fhex); + tohex (sa, + sa_len, + shex); + if ( (fa_len != sa_len) || + (0 != memcmp (fa, + sa, + fa_len)) ) + { + fprintf (stderr, + "FAIL: X-Forwarded-For `%s' gives %s, socket %s gives %s\n", + xff, + fhex, + peer, + shex); + failures++; + } + else if (! cookie_survives (fa, + fa_len, + sa, + sa_len)) + { + fprintf (stderr, + "FAIL: cookie issued via X-Forwarded-For `%s' rejected for %s\n", + xff, + peer); + failures++; + } + else + { + fprintf (stderr, + " ok: `%s' == socket %s == %s\n", + xff, + peer, + fhex); + } + GNUNET_free (fa); + GNUNET_free (sa); +} + + +/** + * Check that @a xff is refused: it is not a bare IP address, so there + * is no canonical form for it and it must not become an identity. + * + * @param xff `X-Forwarded-For` value to reject + */ +static void +refused (const char *xff) +{ + void *ca; + size_t ca_len; + + if (PAIVANA_HTTPD_parse_forwarded_for (xff, + &ca, + &ca_len)) + { + char hex[2 * sizeof (struct in6_addr) + 1]; + + tohex (ca, + ca_len, + hex); + fprintf (stderr, + "FAIL: X-Forwarded-For `%s' accepted as %s, want refusal\n", + xff, + hex); + failures++; + GNUNET_free (ca); + return; + } + if ( (NULL != ca) || + (0 != ca_len) ) + { + fprintf (stderr, + "FAIL: X-Forwarded-For `%s' refused but left ca=%p len=%u\n", + xff, + ca, + (unsigned int) ca_len); + failures++; + return; + } + fprintf (stderr, + " ok: `%s' refused\n", + xff); +} + + +/** + * Check that @a a and @a b, two spellings of one host, are one + * identity. + * + * @param a first spelling + * @param b second spelling + */ +static void +same_identity (const char *a, + const char *b) +{ + void *ca; + void *cb; + size_t ca_len; + size_t cb_len; + + GNUNET_assert (PAIVANA_HTTPD_parse_forwarded_for (a, + &ca, + &ca_len)); + GNUNET_assert (PAIVANA_HTTPD_parse_forwarded_for (b, + &cb, + &cb_len)); + if ( (ca_len != cb_len) || + (0 != memcmp (ca, + cb, + ca_len)) || + (! cookie_survives (ca, + ca_len, + cb, + cb_len)) ) + { + fprintf (stderr, + "FAIL: `%s' and `%s' are one host but not one identity\n", + a, + b); + failures++; + } + else + { + fprintf (stderr, + " ok: `%s' == `%s'\n", + a, + b); + } + GNUNET_free (ca); + GNUNET_free (cb); +} + + +int +main (int argc, + char *const *argv) +{ + (void) argc; + (void) argv; + /* Quiet: the refusal cases trip GNUNET_break_op() by design. */ + GNUNET_assert (GNUNET_OK == + GNUNET_log_setup ("test-client-address", + "ERROR", + NULL)); + GNUNET_CRYPTO_hash ("test-client-address", + strlen ("test-client-address"), + &paivana_secret); + + fprintf (stderr, + "-- a host reaching paivana through a proxy and directly --\n"); + same_host ("203.0.113.7", + "203.0.113.7"); + same_host ("2001:db8::1", + "2001:db8::1"); + same_host ("::1", + "::1"); + same_host ("127.0.0.1", + "127.0.0.1"); + /* A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d; + a proxy in front of it reports the plain IPv4 address. */ + same_host ("203.0.113.7", + "::ffff:203.0.113.7"); + /* The first element is the client; the rest are proxies. */ + same_host ("203.0.113.7, 198.51.100.9, 198.51.100.10", + "203.0.113.7"); + same_host (" 203.0.113.7 ,198.51.100.9", + "203.0.113.7"); + same_host ("\t2001:db8::1\t,198.51.100.9", + "2001:db8::1"); + + fprintf (stderr, + "-- one host, several spellings --\n"); + same_identity ("::1", + "0:0:0:0:0:0:0:1"); + same_identity ("2001:db8::1", + "2001:0db8:0000:0000:0000:0000:0000:0001"); + same_identity ("2001:DB8::1", + "2001:db8::1"); + same_identity ("203.0.113.7", + "::ffff:203.0.113.7"); + + fprintf (stderr, + "-- values that are not bare addresses --\n"); + refused (""); + refused (" "); + refused (","); + refused ("203.0.113.7:4711"); /* port suffix */ + refused ("[2001:db8::1]"); /* bracketed */ + refused ("[2001:db8::1]:443"); + refused ("unknown"); /* RFC 7239 */ + refused ("_hidden"); + refused ("client.example.com"); /* hostname */ + refused ("203.0.113.7 198.51.100.9"); /* no comma */ + refused ("fe80::1%eth0"); /* zone id: RFC 6874 */ + refused ("999.1.1.1"); + refused ("203.0.113"); + refused ("::gggg"); + /* Longer than any address; must not overrun the parse buffer. */ + refused ( + "2001:0db8:0000:0000:0000:0000:0000:0001:0002:0003:0004:0005:0006"); + + fprintf (stderr, + "-- distinct hosts stay distinct --\n"); + { + void *a; + void *b; + size_t al; + size_t bl; + + GNUNET_assert (PAIVANA_HTTPD_parse_forwarded_for ("203.0.113.7", + &a, + &al)); + GNUNET_assert (PAIVANA_HTTPD_parse_forwarded_for ("203.0.113.8", + &b, + &bl)); + if (cookie_survives (a, + al, + b, + bl)) + { + fprintf (stderr, + "FAIL: cookie for 203.0.113.7 accepted for 203.0.113.8\n"); + failures++; + } + else + { + fprintf (stderr, + " ok: cookie for 203.0.113.7 rejected for 203.0.113.8\n"); + } + GNUNET_free (a); + GNUNET_free (b); + } + + if (0 != failures) + { + fprintf (stderr, + "%u check(s) failed\n", + failures); + return 1; + } + fprintf (stderr, + "all checks passed\n"); + return 0; +} diff --git a/src/tests/test_reverse_proxy.sh b/src/tests/test_reverse_proxy.sh @@ -102,6 +102,9 @@ PAIVANA_PID="" # battery derives from it the Host header paivana should be sending # upstream. PAIVANA_DEST="" +# Path of the listening socket when paivana was started by +# start_paivana_unix(); empty for the TCP cases. +PAIVANA_SOCK="" function dump_logs() { echo "-- logs in $LOGDIR --" >&2 @@ -190,6 +193,41 @@ function start_paivana() { fi } +function wait_for_unix_socket() { + # Block until the given path exists and is a socket (max ~5s). + local path="$1" tries=50 + while [ "$tries" -gt 0 ]; + do + [ -S "$path" ] && return 0 + sleep 0.1 + tries=$((tries - 1)) + done + return 1 +} + +function start_paivana_unix() { + # Like start_paivana, but listening on a Unix socket rather than + # TCP -- the shape the shipped packaging deploys. A Unix peer has + # no address, which is exactly what makes it worth testing. + # $1 = upstream base URL; further arguments go to paivana-httpd. + local dest="$1"; shift + PAIVANA_DEST="$dest" + local cfg="$TMPDIR/paivana-unix.conf" + PAIVANA_SOCK="$TMPDIR/paivana.sock" + rm -f "$PAIVANA_SOCK" + sed -e "s|@DEST@|$dest|g" -e "s|@UNIXPATH@|$PAIVANA_SOCK|g" \ + "$SRCDIR/test_reverse_proxy_unix.conf.in" > "$cfg" + local log="$LOGDIR/paivana-unix.log" + ( exec "$PAIVANA_HTTPD" -c "$cfg" -n -L WARNING "$@" ) >"$log" 2>&1 & + PAIVANA_PID=$! + if ! wait_for_unix_socket "$PAIVANA_SOCK"; + then + echo "FAIL: paivana-httpd did not create $PAIVANA_SOCK" >&2 + tail -n 20 "$log" >&2 + exit 1 + fi +} + function stop_paivana() { if [ -n "$PAIVANA_PID" ]; then @@ -833,6 +871,144 @@ function test_pipelined() { } ###################################################################### +# Forwarding headers (X-Forwarded-*), with and without -f. +# +# Which of two roles paivana plays is decided by -f, the same flag +# that decides where the access cookie's client address comes from: +# without it we are the outermost proxy and a client's assertions are +# replaced with what we can see; with it we are behind a trusted proxy +# and extend the chain it gave us. Both directions are asserted here, +# because getting either wrong is silent -- the request still +# succeeds, it just carries the wrong client. +###################################################################### + +# Echo the upstream's view of one header. $1 = header name. +function upstream_header() { + grep -i "^$1:" "$TMPDIR/body" | tr -d '\r' | sed -e "s/^[^:]*: *//" +} + +function test_forwarded_no_flag() { + msg "no -f: client X-Forwarded-* are replaced, not believed" + curl -sS -H 'X-Forwarded-For: 1.2.3.4' \ + -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Host: evil.example.com' \ + -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + local xff proto host + xff="$(upstream_header x-forwarded-for)" + proto="$(upstream_header x-forwarded-proto)" + host="$(upstream_header x-forwarded-host)" + [ "$xff" = "127.0.0.1" ] || \ + fail "X-Forwarded-For='$xff', want '127.0.0.1' (client's 1.2.3.4 must not survive)" + # The client asserted https. We are plain HTTP, and without -f + # nothing the client says about the scheme may be believed -- + # otherwise it picks the scheme of the URLs we generate for it. + [ "$proto" = "http" ] || \ + fail "X-Forwarded-Proto='$proto', want 'http' (client asserted https)" + case "$host" in + *evil.example.com*) fail "client's X-Forwarded-Host reached upstream: '$host'";; + esac + ok +} + +function test_forwarded_with_flag() { + msg "-f: inbound chain is extended, not discarded" + stop_paivana + start_paivana "$PAIVANA_DEST" -f + curl -sS -H 'X-Forwarded-For: 203.0.113.7, 198.51.100.9' \ + -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + local xff + xff="$(upstream_header x-forwarded-for)" + # Our own peer is appended to the right of the chain we were given. + [ "$xff" = "203.0.113.7, 198.51.100.9, 127.0.0.1" ] || \ + fail "X-Forwarded-For='$xff', want '203.0.113.7, 198.51.100.9, 127.0.0.1'" + ok + + msg "-f: trusted X-Forwarded-Proto / -Host are passed through" + curl -sS -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Host: public.example.com' \ + -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + local proto host + proto="$(upstream_header x-forwarded-proto)" + host="$(upstream_header x-forwarded-host)" + [ "$proto" = "https" ] || \ + fail "X-Forwarded-Proto='$proto', want 'https' (trusted proxy said so)" + [ "$host" = "public.example.com" ] || \ + fail "X-Forwarded-Host='$host', want 'public.example.com'" + ok + + msg "-f: no inbound chain still yields our own peer" + curl -sS -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + xff="$(upstream_header x-forwarded-for)" + [ "$xff" = "127.0.0.1" ] || \ + fail "X-Forwarded-For='$xff', want '127.0.0.1'" + ok + + msg "-f: a repeated X-Forwarded-For is combined into one chain" + # RFC 9110 §5.3: two field lines of a list header mean the same as + # one comma-joined line, and must reach the origin as one header. + curl -sS -H 'X-Forwarded-For: 203.0.113.7' \ + -H 'X-Forwarded-For: 198.51.100.9' \ + -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + local n + n="$(grep -ci '^x-forwarded-for:' "$TMPDIR/body")" + [ "$n" = "1" ] || fail "upstream saw $n X-Forwarded-For headers, want 1" + xff="$(upstream_header x-forwarded-for)" + [ "$xff" = "203.0.113.7, 198.51.100.9, 127.0.0.1" ] || \ + fail "X-Forwarded-For='$xff', want '203.0.113.7, 198.51.100.9, 127.0.0.1'" + ok + + stop_paivana + start_paivana "$PAIVANA_DEST" +} + +function test_forwarded_unix() { + # The deployment the Debian packaging actually ships: paivana on a + # Unix socket behind nginx/Apache. A Unix peer has no address, so + # without -f there is nothing to put in X-Forwarded-For at all, and + # with -f the inbound chain is the only client information that + # exists -- losing it leaves the origin blind. + msg "unix socket, -f: inbound chain survives the address-less hop" + local dest="$PAIVANA_DEST" + stop_paivana + start_paivana_unix "$dest" -f + curl -sS --unix-socket "$PAIVANA_SOCK" \ + -H 'X-Forwarded-For: 203.0.113.7' \ + -o "$TMPDIR/body" http://localhost/echo-headers 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + local xff + xff="$(upstream_header x-forwarded-for)" + # Nothing is appended: a Unix peer has no address, and inventing + # one ("127.0.0.1") would be indistinguishable from a real + # loopback client. The hop is recorded in Via instead. + [ "$xff" = "203.0.113.7" ] || \ + fail "X-Forwarded-For='$xff', want '203.0.113.7' (unadorned)" + grep -qi '^via:.*paivana' "$TMPDIR/body" || \ + fail "Via does not record the paivana hop; headers:\n$(cat "$TMPDIR/body")" + ok + + msg "unix socket, no -f: no X-Forwarded-For is invented" + stop_paivana + start_paivana_unix "$dest" + curl -sS --unix-socket "$PAIVANA_SOCK" \ + -H 'X-Forwarded-For: 1.2.3.4' \ + -o "$TMPDIR/body" http://localhost/echo-headers 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + grep -qi '^x-forwarded-for:' "$TMPDIR/body" && \ + fail "upstream saw an X-Forwarded-For we cannot substantiate:\n$(cat "$TMPDIR/body")" + grep -qi '^via:.*paivana' "$TMPDIR/body" || \ + fail "Via does not record the paivana hop" + ok + + stop_paivana + start_paivana "$dest" +} + +###################################################################### # Drive the tests. ###################################################################### @@ -856,6 +1032,9 @@ test_upload_too_big_chunked test_keepalive_curl test_wget_basic test_pipelined +test_forwarded_no_flag +test_forwarded_with_flag +test_forwarded_unix stop_paivana diff --git a/src/tests/test_reverse_proxy_unix.conf.in b/src/tests/test_reverse_proxy_unix.conf.in @@ -0,0 +1,18 @@ +# Paivana configuration for the Unix-socket cases of the reverse-proxy +# test suite. This is the shape the shipped Debian packaging uses +# (nginx/Apache in front, paivana-httpd on a Unix socket), which the +# TCP template cannot exercise: a Unix peer has no address of its own, +# so it is the only way to reach the code paths that have to cope +# without one. + +[paivana] +DESTINATION_BASE_URL = @DEST@ +SERVE = unix +UNIXPATH = @UNIXPATH@ +UNIXPATH_MODE = 600 +BASE_URL = http://localhost/ + +# In -n (no-payment) mode the merchant backend is not contacted, +# so these values are nominal but we still need them to satisfy +# the loader when the merchant config key happens to be present. +SECRET = paivana-test-42