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:
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