paivana

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

commit 96d82f9364d770bbf0cd63aed6c11380e0564210
parent 9cd622d3b04c7d4a15b85c9f7a7af4200bf3d1f1
Author: Christian Grothoff <christian@grothoff.org>
Date:   Tue,  4 Aug 2026 18:44:55 +0200

support RFC 7239 Forwarded alongside X-Forwarded-*

X-Forwarded-For is a de-facto header with no specification; RFC 7239
standardizes the same information and carries the scheme, the
authority and the receiving interface in one place.  Read it, prefer
it where both are present, and emit it upstream in addition to the
de-facto headers, which the majority of origins still speak.

Because Forwarded wins where both are present, its for/proto/host are
also mirrored into the X-Forwarded-* headers we emit: a proxy that
speaks only RFC 7239 would otherwise leave an origin that speaks only
the de-facto headers believing the proxy was the client, and the
request plain HTTP.  The mirroring is all-or-nothing, since
X-Forwarded-For cannot express an address-less hop and dropping one
would silently shift every position to its left.

That gap is also why paivana's own element reads for=unknown over a
Unix socket where X-Forwarded-For gets nothing: §6.3 provides the
spelling, so the hop need not go unrecorded.

The shipped nginx and Apache configurations now emit Forwarded too.
Both set rather than append, as each is the outermost hop; nginx needs
a map for the element because it has no variable that brackets and
quotes an IPv6 address.

Diffstat:
MREADME | 15++++++++++++++-
Mdebian/etc/apache2/sites-available/paivana.conf | 8++++++++
Mdebian/etc/nginx/sites-available/paivana | 21++++++++++++++++++---
Msrc/backend/paivana-httpd_helper.c | 435++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/backend/paivana-httpd_helper.h | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/paivana-httpd_reverse.c | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/tests/README | 24++++++++++++++++++++++++
Msrc/tests/test_client_address.c | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/tests/test_reverse_proxy.sh | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1104 insertions(+), 25 deletions(-)

diff --git a/README b/README @@ -133,6 +133,11 @@ 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. +Paivana reads both the RFC 7239 `Forwarded` header and the de-facto +`X-Forwarded-*` ones, preferring `Forwarded` where both are present, +and emits both upstream — the standardized one for origins that speak +it, the de-facto ones for the many that do not. + `-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 @@ -162,7 +167,11 @@ nginx (`/etc/nginx/sites-available/paivana`): 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 ""; + + # RFC 7239; preferred by Paivana over the above. The + # element is built by a `map` — see the shipped config. + proxy_set_header Forwarded \ + "$paivana_forwarded_elem;proto=$scheme;host=$host"; } } @@ -177,6 +186,10 @@ Apache (requires mod_proxy, mod_proxy_http and mod_headers): RequestHeader unset X-Forwarded-Port RequestHeader unset Forwarded + # RFC 7239; Apache emits none of its own. + 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/apache2/sites-available/paivana.conf b/debian/etc/apache2/sites-available/paivana.conf @@ -25,5 +25,13 @@ 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,3 +1,13 @@ +# 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; @@ -25,8 +35,13 @@ server { 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 ""; + # 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/src/backend/paivana-httpd_helper.c b/src/backend/paivana-httpd_helper.c @@ -187,6 +187,416 @@ count_elements (const char *xff) } +/** + * Locate element number @a idx of the RFC 7239 `Forwarded` list + * @a fwd and return the value of its @a name parameter, with any + * surrounding quotes removed. + * + * Commas and semicolons inside a quoted string are data, not + * delimiters (RFC 7239 §4 via RFC 9110 §5.6.4), so the scan has to + * track quoting rather than reach for strchr(). + * + * A quoted-pair escape is taken verbatim: none of the values we care + * about (an address, a scheme, an authority) can legitimately contain + * one, so anything that needed unescaping will fail the checks the + * caller applies anyway. + * + * @param fwd header value to scan + * @param idx which element to read, counting from 0 at the left + * @param name parameter to look for, e.g. "for" + * @param[out] out buffer for the extracted value + * @param out_size number of bytes in @a out + * @return true if element @a idx exists and carries @a name + */ +static bool +nth_forwarded_param (const char *fwd, + unsigned int idx, + const char *name, + char *out, + size_t out_size) +{ + const char *p = fwd; + const size_t namelen = strlen (name); + unsigned int elem = 0; + bool in_quotes = false; + const char *pair = NULL; + + for (; ; p++) + { + if (in_quotes) + { + if ('\\' == *p) + { + if ('\0' == p[1]) + return false; + p++; + continue; + } + if ('"' == *p) + in_quotes = false; + continue; + } + if ('"' == *p) + { + in_quotes = true; + continue; + } + if ( (',' != *p) && + (';' != *p) && + ('\0' != *p) ) + { + /* Skip the whitespace that follows a delimiter: "a, b" starts + its second element at 'b', not at the space. */ + if ( (NULL == pair) && + (' ' != *p) && + ('\t' != *p) ) + pair = p; + continue; + } + /* End of one parameter. Keep it if it is the one we were asked + for, in the element we were asked for. */ + if ( (NULL != pair) && + (elem == idx) && + (0 == strncasecmp (pair, + name, + namelen)) ) + { + const char *v = pair + namelen; + + while ( (' ' == *v) || + ('\t' == *v) ) + v++; + if ('=' == *v) + { + size_t len; + + v++; + while ( (' ' == *v) || + ('\t' == *v) ) + v++; + len = (size_t) (p - v); + while ( (len > 0) && + ( (' ' == v[len - 1]) || + ('\t' == v[len - 1]) ) ) + len--; + if ( (len >= 2) && + ('"' == v[0]) && + ('"' == v[len - 1]) ) + { + v++; + len -= 2; + } + if (len >= out_size) + return false; + memcpy (out, + v, + len); + out[len] = '\0'; + return true; + } + } + if ('\0' == *p) + return false; + if (',' == *p) + { + if (elem == idx) + return false; /* element ended without the parameter */ + elem++; + } + pair = NULL; + } +} + + +/** + * Reduce an RFC 7239 node identifier to a bare address by removing + * brackets and any port, in place. + * + * RFC 7239 §6 permits `[2001:db8::1]:443` and `203.0.113.7:80`; a + * port is not part of the identity of a host and the cookie must not + * depend on it. + * + * @param[in,out] node identifier to trim + */ +static void +strip_node_port (char *node) +{ + size_t len = strlen (node); + + if ( (len > 0) && + ('[' == node[0]) ) + { + char *close = strchr (node, + ']'); + + if (NULL == close) + return; + *close = '\0'; + memmove (node, + node + 1, + strlen (node + 1) + 1); + return; + } + { + char *colon = strchr (node, + ':'); + + /* Only a *single* colon can be a port separator; more than one + means this is a bare IPv6 address, which RFC 7239 requires to + be bracketed but which we accept anyway. */ + if ( (NULL != colon) && + (NULL == strchr (colon + 1, + ':')) ) + *colon = '\0'; + } +} + + +/** + * Read the `for=` of element @a idx as a bare address. + * + * @param fwd header value to scan + * @param idx which element to read + * @param[out] out buffer for the address + * @param out_size number of bytes in @a out + * @return true if element @a idx exists and carries a `for=` + */ +static bool +nth_forwarded_for (const char *fwd, + unsigned int idx, + char *out, + size_t out_size) +{ + if (! nth_forwarded_param (fwd, + idx, + "for", + out, + out_size)) + return false; + strip_node_port (out); + return true; +} + + +char * +PAIVANA_HTTPD_forwarded_param (const char *fwd, + const char *name) +{ + /* Long enough for an authority; anything longer is not one. */ + char buf[256]; + + /* The leftmost element describes the connection the client itself + made, which is what `proto` and `host` are being asked about. + With a single proxy in front -- the ordinary case -- there is only + one element and the question does not arise. */ + if (! nth_forwarded_param (fwd, + 0, + name, + buf, + sizeof (buf))) + return NULL; + if ('\0' == buf[0]) + return NULL; + return GNUNET_strdup (buf); +} + + +/** + * Count the elements of the RFC 7239 list @a fwd, respecting quoting. + * + * @param fwd header value to scan + * @return number of elements (at least 1 for a non-empty string) + */ +static unsigned int +count_forwarded_elements (const char *fwd) +{ + unsigned int n = 1; + bool in_quotes = false; + + for (const char *p = fwd; '\0' != *p; p++) + { + if (in_quotes) + { + if ('\\' == *p) + { + if ('\0' == p[1]) + break; + p++; + continue; + } + if ('"' == *p) + in_quotes = false; + continue; + } + if ('"' == *p) + in_quotes = true; + else if (',' == *p) + n++; + } + return n; +} + + +char * +PAIVANA_HTTPD_forwarded_for_chain (const char *fwd) +{ + struct GNUNET_Buffer buf = { 0 }; + unsigned int n = count_forwarded_elements (fwd); + + for (unsigned int i = 0; i < n; i++) + { + char node[INET6_ADDRSTRLEN]; + struct in_addr a4; + struct in6_addr a6; + + if (! nth_forwarded_for (fwd, + i, + node, + sizeof (node))) + { + GNUNET_buffer_clear (&buf); + return NULL; + } + /* All or nothing: X-Forwarded-For has no way to say "this hop had + no address", so an element whose `for` is RFC 7239's "unknown", + an obfuscated identifier, or anything else that is not an + address cannot be represented. Dropping it would silently + shift every position to its left, which is worse than declining + to translate the header at all. */ + if ( (1 != inet_pton (AF_INET, + node, + &a4)) && + (1 != inet_pton (AF_INET6, + node, + &a6)) ) + { + GNUNET_buffer_clear (&buf); + return NULL; + } + if (0 != i) + GNUNET_buffer_write_str (&buf, + ", "); + GNUNET_buffer_write_str (&buf, + node); + } + return GNUNET_buffer_reap_str (&buf); +} + + +bool +PAIVANA_HTTPD_parse_forwarded (const char *fwd, + void **ca, + size_t *ca_len) +{ + unsigned int n; + char node[INET6_ADDRSTRLEN]; + + *ca = NULL; + *ca_len = 0; + n = count_forwarded_elements (fwd); + if (! PH_have_trusted_proxies) + { + if (! nth_forwarded_for (fwd, + 0, + node, + sizeof (node))) + { + GNUNET_break_op (0); + return false; + } + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Client address is based on Forwarded: `%s'\n", + node); + return parse_element (node, + strlen (node), + ca, + ca_len); + } + /* Same walk as for X-Forwarded-For; see there. */ + for (unsigned int i = n; i > 0; i--) + { + if (! nth_forwarded_for (fwd, + i - 1, + node, + sizeof (node))) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Forwarded element without a usable `for'; not looking" + " further left in the chain\n"); + return false; + } + if (! parse_element (node, + strlen (node), + ca, + ca_len)) + return false; + if (! PAIVANA_HTTPD_is_trusted_proxy (*ca, + *ca_len)) + { + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Client address is based on Forwarded: `%s'\n", + node); + return true; + } + GNUNET_free (*ca); + *ca = NULL; + *ca_len = 0; + } + if (! nth_forwarded_for (fwd, + 0, + node, + sizeof (node))) + { + GNUNET_break (0); + return false; + } + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "All Forwarded elements are trusted proxies; using the leftmost" + " `%s' as the client\n", + node); + return parse_element (node, + strlen (node), + ca, + ca_len); +} + + +char * +PAIVANA_HTTPD_forwarded_node (const void *ca, + size_t ca_len) +{ + char buf[INET6_ADDRSTRLEN]; + char *ret; + + if ( (NULL == ca) || + (0 == ca_len) ) + { + /* RFC 7239 §6.3 provides exactly this for a hop whose predecessor + has no address we can name -- a Unix-domain peer, here. */ + return GNUNET_strdup ("unknown"); + } + if (sizeof (struct in_addr) == ca_len) + { + GNUNET_assert (NULL != inet_ntop (AF_INET, + ca, + buf, + sizeof (buf))); + return GNUNET_strdup (buf); + } + GNUNET_assert (sizeof (struct in6_addr) == ca_len); + GNUNET_assert (NULL != inet_ntop (AF_INET6, + ca, + buf, + sizeof (buf))); + /* RFC 7239 §6: an IPv6 identifier is bracketed, and the brackets + force the whole thing to be a quoted-string. */ + GNUNET_asprintf (&ret, + "\"[%s]\"", + buf); + return ret; +} + + bool PAIVANA_HTTPD_is_trusted_proxy (const void *ca, size_t ca_len) @@ -410,9 +820,32 @@ PAIVANA_HTTPD_get_client_address (struct MHD_Connection *connection, (PAIVANA_HTTPD_is_trusted_proxy (*ca, *ca_len)) ) { + const char *fwd; + + /* RFC 7239 is the standardized form and says more than the + de-facto header does, so it wins where both are present. A + proxy that emits both should agree with itself; if it does + not, we would rather be predictable than clever. */ + fwd = MHD_lookup_connection_value (connection, + MHD_HEADER_KIND, + MHD_HTTP_HEADER_FORWARDED); xff = MHD_lookup_connection_value (connection, MHD_HEADER_KIND, PH_HEADER_X_FORWARDED_FOR); + if ( (NULL != fwd) && + (NULL != xff) ) + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Both Forwarded and X-Forwarded-For present; using" + " Forwarded\n"); + if (NULL != fwd) + { + GNUNET_free (*ca); + *ca = NULL; + *ca_len = 0; + return PAIVANA_HTTPD_parse_forwarded (fwd, + ca, + ca_len); + } if (NULL != xff) { GNUNET_free (*ca); @@ -422,7 +855,7 @@ PAIVANA_HTTPD_get_client_address (struct MHD_Connection *connection, ca, ca_len); } - /* No header present: the socket address stands. */ + /* Neither header present: the socket address stands. */ } else { diff --git a/src/backend/paivana-httpd_helper.h b/src/backend/paivana-httpd_helper.h @@ -84,6 +84,79 @@ PAIVANA_HTTPD_parse_forwarded_for (const char *xff, /** + * Extract the client address from a `Forwarded` header value + * (RFC 7239). + * + * The standardized spelling of #PAIVANA_HTTPD_parse_forwarded_for(), + * and selects an element by the same rule. Only the `for=` parameter + * of each element is of interest here; a node identifier that is not + * an address — the `unknown` of §6.3, an obfuscated `_secret`, or a + * hostname — is not one we can bind a cookie to and ends the walk. + * Ports are stripped: RFC 7239 §6 permits `for="[2001:db8::1]:443"`, + * and the port is not part of the host's identity. + * + * @param fwd value of the `Forwarded` header + * @param[out] ca where to write the client address + * @param[out] ca_len number of bytes in @a ca + * @return true on success, false if no usable address could be found + */ +bool +PAIVANA_HTTPD_parse_forwarded (const char *fwd, + void **ca, + size_t *ca_len); + + +/** + * Render the `for=` chain of a `Forwarded` header value in + * `X-Forwarded-For` form, so that an origin which speaks only the + * de-facto header still learns the client. + * + * Returns NULL unless *every* element carries a bare IP address: + * `X-Forwarded-For` has no way to say "this hop had no address", so an + * element whose `for` is RFC 7239's `unknown` or an obfuscated + * identifier cannot be represented, and omitting it would silently + * shift every position to its left. + * + * @param fwd value of the `Forwarded` header + * @return the comma-separated chain, or NULL; to be freed by the + * caller + */ +char * +PAIVANA_HTTPD_forwarded_for_chain (const char *fwd); + + +/** + * Read a parameter of the leftmost element of a `Forwarded` header + * value, e.g. `proto` or `host`. + * + * The leftmost element describes the connection the client itself + * made, which is what those two are being asked about. + * + * @param fwd value of the `Forwarded` header + * @param name parameter to look for + * @return the value with any quoting removed, or NULL if the header + * does not carry it; to be freed by the caller + */ +char * +PAIVANA_HTTPD_forwarded_param (const char *fwd, + const char *name); + + +/** + * Render @a ca as an RFC 7239 `for=` node identifier, quoting and + * bracketing an IPv6 address as §6 requires. + * + * @param ca address in binary form, or NULL for a peer that has none + * @param ca_len number of bytes in @a ca + * @return the identifier, e.g. "203.0.113.7", "\"[2001:db8::1]\"" or + * "unknown"; to be freed by the caller + */ +char * +PAIVANA_HTTPD_forwarded_node (const void *ca, + size_t ca_len); + + +/** * Is @a ca the address of a reverse proxy we trust to report the * client address truthfully, i.e. one covered by `TRUSTED_PROXIES` or * `TRUSTED_PROXIES6`? diff --git a/src/backend/paivana-httpd_reverse.c b/src/backend/paivana-httpd_reverse.c @@ -33,6 +33,7 @@ #include <taler/taler_mhd_lib.h> #include "paivana-httpd.h" #include "paivana-httpd_cookie.h" +#include "paivana-httpd_helper.h" #include "paivana-httpd_reverse.h" @@ -297,6 +298,14 @@ struct HttpRequest char *client_xfh; /** + * Concatenated value of the client's `Forwarded` header(s), if any + * (RFC 7239). The standardized form of @e client_xff; like it, our + * own element is appended when #PH_respect_forwarded_headers is + * set, and it is replaced outright when it is not. + */ + char *client_forwarded; + + /** * 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 @@ -641,6 +650,9 @@ collect_proxy_state (void *cls, else if (0 == strcasecmp (PH_HEADER_X_FORWARDED_FOR, key)) target = &hr->client_xff; + else if (0 == strcasecmp (MHD_HTTP_HEADER_FORWARDED, + key)) + target = &hr->client_forwarded; else return MHD_YES; append_list_value (target, @@ -1221,6 +1233,7 @@ PAIVANA_HTTPD_reverse_cleanup (struct HttpRequest *hr) GNUNET_free (hr->client_xff); GNUNET_free (hr->client_xfp); GNUNET_free (hr->client_xfh); + GNUNET_free (hr->client_forwarded); GNUNET_free (hr->client_connection); GNUNET_free (hr->upstream_connection); GNUNET_CONTAINER_DLL_remove (hr_head, @@ -1515,18 +1528,32 @@ peer_address (struct MHD_Connection *con, * ask MHD about the transport instead — a TLS session exists or it * does not. * + * @param hr request we are handling, for the captured inbound headers * @param con MHD connection we are processing - * @param client_xfp inbound `X-Forwarded-Proto`, or NULL + * @param[out] owned set to an allocated string the caller must free, + * or NULL if the returned value is a literal or borrowed * @return "https" or "http" */ static const char * -forwarded_proto (struct MHD_Connection *con, - const char *client_xfp) +forwarded_proto (const struct HttpRequest *hr, + struct MHD_Connection *con, + char **owned) { + *owned = NULL; if (PH_respect_forwarded_headers) { - if (NULL != client_xfp) - return client_xfp; + if (NULL != hr->client_xfp) + return hr->client_xfp; + /* A proxy that speaks only RFC 7239 states the scheme there and + nowhere else; ignoring it would tell the origin "http" about a + request the client made over TLS. */ + if (NULL != hr->client_forwarded) + { + *owned = PAIVANA_HTTPD_forwarded_param (hr->client_forwarded, + "proto"); + if (NULL != *owned) + return *owned; + } return (GNUNET_YES == TALER_mhd_is_https (con)) ? "https" : "http"; } @@ -1556,9 +1583,9 @@ forwarded_proto (struct MHD_Connection *con, * 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. + * `Forwarded` (RFC 7239) is treated exactly like `X-Forwarded-For`, + * and both are emitted: the standardized header for origins that + * speak it, the de-facto one for the many that only speak that. * * @param[in,out] hr the request * @param con MHD connection we are processing @@ -1571,15 +1598,30 @@ append_forwarded_headers (struct HttpRequest *hr, { char ipbuf[INET6_ADDRSTRLEN]; char *hdr; + char *owned_proto; + char *owned_host = NULL; + char *owned_xff = NULL; + const char *xff; const char *peer; - const char *fhost; + const char *proto; + const char *fhost = NULL; const char *via_ver = "1.1"; peer = peer_address (con, ipbuf, sizeof (ipbuf)); + xff = hr->client_xff; if ( (PH_respect_forwarded_headers) && - (NULL != hr->client_xff) ) + (NULL == xff) && + (NULL != hr->client_forwarded) ) + { + /* A proxy that speaks only RFC 7239 still has to be understood by + an origin that speaks only X-Forwarded-For. */ + owned_xff = PAIVANA_HTTPD_forwarded_for_chain (hr->client_forwarded); + xff = owned_xff; + } + if ( (PH_respect_forwarded_headers) && + (NULL != xff) ) { /* Extend the trusted chain. If our own peer has no address (Unix socket) the chain is passed on as it stands. */ @@ -1587,13 +1629,13 @@ append_forwarded_headers (struct HttpRequest *hr, GNUNET_asprintf (&hdr, "%s: %s, %s", PH_HEADER_X_FORWARDED_FOR, - hr->client_xff, + xff, peer); else GNUNET_asprintf (&hdr, "%s: %s", PH_HEADER_X_FORWARDED_FOR, - hr->client_xff); + xff); hr->headers = curl_slist_append (hr->headers, hdr); GNUNET_free (hdr); @@ -1608,20 +1650,31 @@ append_forwarded_headers (struct HttpRequest *hr, hdr); GNUNET_free (hdr); } + proto = forwarded_proto (hr, + con, + &owned_proto); GNUNET_asprintf (&hdr, "%s: %s", PH_HEADER_X_FORWARDED_PROTO, - forwarded_proto (con, - hr->client_xfp)); + proto); hr->headers = curl_slist_append (hr->headers, hdr); GNUNET_free (hdr); - 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 (PH_respect_forwarded_headers) + { + fhost = hr->client_xfh; + if ( (NULL == fhost) && + (NULL != hr->client_forwarded) ) + { + owned_host = PAIVANA_HTTPD_forwarded_param (hr->client_forwarded, + "host"); + fhost = owned_host; + } + } + if (NULL == fhost) + fhost = MHD_lookup_connection_value (con, + MHD_HEADER_KIND, + MHD_HTTP_HEADER_HOST); if (NULL != fhost) { GNUNET_asprintf (&hdr, @@ -1632,6 +1685,74 @@ append_forwarded_headers (struct HttpRequest *hr, hdr); GNUNET_free (hdr); } + /* RFC 7239. The element we add describes the hop we are completing: + `for` is the peer we accepted from, `by` the interface we accepted + on, and `proto`/`host` what the client used. A Unix-domain peer + has no address, which §6.3 spells "unknown" -- unlike + X-Forwarded-For, this header has somewhere to put that, so the hop + need not go unrecorded. */ + { + char *elem; + char *node; + void *ca = NULL; + size_t ca_len = 0; + + if (NULL != peer) + { + struct in_addr a4; + struct in6_addr a6; + + if (1 == inet_pton (AF_INET, + peer, + &a4)) + { + ca = &a4; + ca_len = sizeof (a4); + } + else if (1 == inet_pton (AF_INET6, + peer, + &a6)) + { + ca = &a6; + ca_len = sizeof (a6); + } + node = PAIVANA_HTTPD_forwarded_node (ca, + ca_len); + } + else + { + node = PAIVANA_HTTPD_forwarded_node (NULL, + 0); + } + if (NULL != fhost) + GNUNET_asprintf (&elem, + "for=%s;by=_paivana;proto=%s;host=%s", + node, + proto, + fhost); + else + GNUNET_asprintf (&elem, + "for=%s;by=_paivana;proto=%s", + node, + proto); + GNUNET_free (node); + if ( (PH_respect_forwarded_headers) && + (NULL != hr->client_forwarded) ) + GNUNET_asprintf (&hdr, + "%s: %s, %s", + MHD_HTTP_HEADER_FORWARDED, + hr->client_forwarded, + elem); + else + GNUNET_asprintf (&hdr, + "%s: %s", + MHD_HTTP_HEADER_FORWARDED, + elem); + GNUNET_free (elem); + hr->headers = curl_slist_append (hr->headers, + hdr); + GNUNET_free (hdr); + } /* MHD hands us e.g. "HTTP/1.1" but Via wants just "1.1". */ if ( (NULL != ver) && (0 == strncasecmp (ver, @@ -1652,6 +1773,9 @@ append_forwarded_headers (struct HttpRequest *hr, hr->headers = curl_slist_append (hr->headers, hdr); GNUNET_free (hdr); + GNUNET_free (owned_proto); + GNUNET_free (owned_host); + GNUNET_free (owned_xff); } diff --git a/src/tests/README b/src/tests/README @@ -139,6 +139,22 @@ Forwarding-header tests (run once): X-Forwarded-For is emitted at all. This is the only case that reaches the address-less code paths. + RFC 7239 Forwarded the standardized header is handled like + X-Forwarded-For: extended under -f, + replaced without it. Since paivana + prefers it when both are present, its + for/proto/host must also reach the + X-Forwarded-* headers, or an origin that + speaks only those would be told the + proxy was the client. A chain + containing a hop X-Forwarded-For cannot + express (§6.3 "unknown") yields no + synthesized chain rather than one with a + hop silently missing. + unix socket, Forwarded unlike X-Forwarded-For, RFC 7239 can + name an address-less hop, so paivana's + own element reads for=unknown rather + than being omitted. TRUSTED_PROXIES startup a policy that parses to nothing usable (missing trailing ';', a /0 network, an address of the wrong family, junk) must @@ -236,6 +252,14 @@ TRUSTED_PROXIES6): - that with no policy configured the leftmost element is used and nothing is trusted. +A further group covers the RFC 7239 `Forwarded` parser: quoting, +bracketed and unbracketed IPv6, port suffixes, parameters in any +order, commas inside quoted strings (which are data, not element +boundaries), node identifiers that are not addresses ("unknown", +obfuscated "_secret", hostnames), the same right-to-left walk under a +trusted-proxy policy, and the rendering back out — as a `for=` +identifier and as an X-Forwarded-For chain. + A separate group pins the behaviour of GNUnet's GNUNET_STRINGS_parse_ipv{4,6}_policy() that load_trusted_proxies() compensates for: the mandatory trailing ';', the v4/v6 disagreement diff --git a/src/tests/test_client_address.c b/src/tests/test_client_address.c @@ -495,6 +495,223 @@ trusted_is (const char *addr, /** + * Check that @a fwd, walked under the policy currently installed, + * names @a want as the client. + * + * @param fwd `Forwarded` value + * @param want expected client address in presentation form + */ +static void +fwd_client_is (const char *fwd, + const char *want) +{ + void *got; + void *exp; + size_t got_len; + size_t exp_len; + + socket_address (want, + &exp, + &exp_len); + if (! PAIVANA_HTTPD_parse_forwarded (fwd, + &got, + &got_len)) + { + fprintf (stderr, + "FAIL: Forwarded `%s' rejected, want client %s\n", + fwd, + want); + failures++; + GNUNET_free (exp); + return; + } + if ( (got_len != exp_len) || + (0 != memcmp (got, + exp, + got_len)) ) + { + char ghex[2 * sizeof (struct in6_addr) + 1]; + + tohex (got, + got_len, + ghex); + fprintf (stderr, + "FAIL: Forwarded `%s' gives %s, want %s\n", + fwd, + ghex, + want); + failures++; + } + else + { + fprintf (stderr, + " ok: Forwarded `%s' -> %s\n", + fwd, + want); + } + GNUNET_free (got); + GNUNET_free (exp); +} + + +/** + * Check that @a fwd yields no client address. + * + * @param fwd `Forwarded` value to reject + */ +static void +fwd_refused (const char *fwd) +{ + void *ca; + size_t ca_len; + + if (PAIVANA_HTTPD_parse_forwarded (fwd, + &ca, + &ca_len)) + { + char hex[2 * sizeof (struct in6_addr) + 1]; + + tohex (ca, + ca_len, + hex); + fprintf (stderr, + "FAIL: Forwarded `%s' accepted as %s, want refusal\n", + fwd, + hex); + failures++; + GNUNET_free (ca); + return; + } + fprintf (stderr, + " ok: Forwarded `%s' refused\n", + fwd); +} + + +/** + * Check that parameter @a name of @a fwd reads as @a want (NULL for + * "not present"). + * + * @param fwd `Forwarded` value + * @param name parameter to read + * @param want expected value, or NULL + */ +static void +fwd_param_is (const char *fwd, + const char *name, + const char *want) +{ + char *got; + + got = PAIVANA_HTTPD_forwarded_param (fwd, + name); + if ( ( (NULL == got) != (NULL == want) ) || + ( (NULL != got) && + (0 != strcmp (got, + want)) ) ) + { + fprintf (stderr, + "FAIL: `%s' %s=%s, want %s\n", + fwd, + name, + (NULL != got) ? got : "(none)", + (NULL != want) ? want : "(none)"); + failures++; + } + else + { + fprintf (stderr, + " ok: `%s' %s=%s\n", + fwd, + name, + (NULL != got) ? got : "(none)"); + } + GNUNET_free (got); +} + + +/** + * Check that @a literal renders as the RFC 7239 node identifier + * @a want. + * + * @param literal address in presentation form, or NULL for a peer + * that has none + * @param want expected identifier + */ +static void +node_is (const char *literal, + const char *want) +{ + void *ca = NULL; + size_t ca_len = 0; + char *got; + + if (NULL != literal) + socket_address (literal, + &ca, + &ca_len); + got = PAIVANA_HTTPD_forwarded_node (ca, + ca_len); + if (0 != strcmp (got, + want)) + { + fprintf (stderr, + "FAIL: node for %s is `%s', want `%s'\n", + (NULL != literal) ? literal : "(no address)", + got, + want); + failures++; + } + else + { + fprintf (stderr, + " ok: node for %s is `%s'\n", + (NULL != literal) ? literal : "(no address)", + got); + } + GNUNET_free (got); + GNUNET_free (ca); +} + + +/** + * Check that @a fwd renders as the X-Forwarded-For chain @a want + * (NULL if it cannot be represented). + * + * @param fwd `Forwarded` value + * @param want expected chain, or NULL + */ +static void +chain_is (const char *fwd, + const char *want) +{ + char *got; + + got = PAIVANA_HTTPD_forwarded_for_chain (fwd); + if ( ( (NULL == got) != (NULL == want) ) || + ( (NULL != got) && + (0 != strcmp (got, + want)) ) ) + { + fprintf (stderr, + "FAIL: chain of `%s' is `%s', want `%s'\n", + fwd, + (NULL != got) ? got : "(none)", + (NULL != want) ? want : "(none)"); + failures++; + } + else + { + fprintf (stderr, + " ok: chain of `%s' is `%s'\n", + fwd, + (NULL != got) ? got : "(none)"); + } + GNUNET_free (got); +} + + +/** * Pin the behaviour of the GNUnet policy parsers that * load_trusted_proxies() has to compensate for. These are not our * functions, and their edge cases are what the configuration loader @@ -713,6 +930,88 @@ main (int argc, "2001:0db8:0000:0000:0000:0000:0000:0001:0002:0003:0004:0005:0006"); fprintf (stderr, + "-- RFC 7239 Forwarded --\n"); + fwd_client_is ("for=203.0.113.7", + "203.0.113.7"); + fwd_client_is ("For=203.0.113.7", /* case-insensitive */ + "203.0.113.7"); + fwd_client_is ("for=\"203.0.113.7\"", /* quoted */ + "203.0.113.7"); + fwd_client_is ("for=\"[2001:db8::1]\"", /* §6: bracketed */ + "2001:db8::1"); + fwd_client_is ("for=\"[2001:db8::1]:443\"", /* ...with a port */ + "2001:db8::1"); + fwd_client_is ("for=203.0.113.7:8080", /* v4 with a port */ + "203.0.113.7"); + fwd_client_is ("for=2001:db8::1", /* unbracketed v6 */ + "2001:db8::1"); + fwd_client_is ("proto=https;for=203.0.113.7;host=e.com", /* not first */ + "203.0.113.7"); + fwd_client_is ("for = 203.0.113.7", /* space around '=' */ + "203.0.113.7"); + /* No policy: the leftmost element is the client. */ + fwd_client_is ("for=203.0.113.7, for=198.51.100.9", + "203.0.113.7"); + /* A comma inside a quoted string is data, not an element boundary + (RFC 7239 §4 via RFC 9110 §5.6.4). */ + fwd_client_is ("host=\"a,b\";for=203.0.113.7", + "203.0.113.7"); + fwd_refused ("for=unknown"); /* §6.3: no address */ + fwd_refused ("for=_hidden"); /* §6.3: obfuscated */ + fwd_refused ("for=client.example.com"); /* a name, not an address */ + fwd_refused ("proto=https;host=e.com"); /* no `for' at all */ + fwd_refused (""); + fwd_refused ("for="); + fwd_refused ("for=\"[2001:db8::1\""); /* unclosed bracket */ + fwd_refused ("for=\"unterminated"); /* unclosed quote */ + + fprintf (stderr, + "-- Forwarded parameters and node rendering --\n"); + fwd_param_is ("for=203.0.113.7;proto=https;host=e.com", + "proto", + "https"); + fwd_param_is ("for=203.0.113.7;proto=https;host=e.com", + "host", + "e.com"); + fwd_param_is ("for=203.0.113.7;host=\"e.com:8443\"", + "host", + "e.com:8443"); + fwd_param_is ("for=203.0.113.7", + "proto", + NULL); + /* The leftmost element describes the client's own connection. */ + fwd_param_is ("proto=https, proto=http", + "proto", + "https"); + node_is ("203.0.113.7", + "203.0.113.7"); + node_is ("2001:db8::1", + "\"[2001:db8::1]\""); + node_is (NULL, + "unknown"); + chain_is ("for=203.0.113.7, for=198.51.100.9", + "203.0.113.7, 198.51.100.9"); + chain_is ("for=\"[2001:db8::1]:443\", for=10.0.0.1", + "2001:db8::1, 10.0.0.1"); + /* Not every element is an address, so no chain can be rendered. */ + chain_is ("for=unknown, for=10.0.0.1", + NULL); + + fprintf (stderr, + "-- walking a Forwarded chain under a policy --\n"); + set_policy ("10.0.0.0/8;", NULL); + fwd_client_is ("for=203.0.113.7, for=10.0.0.1", + "203.0.113.7"); + fwd_client_is ("for=1.2.3.4, for=203.0.113.7, for=10.0.0.1", + "203.0.113.7"); + fwd_client_is ("for=unknown, for=203.0.113.7, for=10.0.0.1", + "203.0.113.7"); + fwd_client_is ("for=10.0.0.1, for=10.0.0.2", + "10.0.0.1"); + fwd_refused ("for=unknown, for=10.0.0.1"); + set_policy (NULL, NULL); + + fprintf (stderr, "-- the GNUnet policy parsers behave as the loader assumes --\n"); test_policy_parser (); diff --git a/src/tests/test_reverse_proxy.sh b/src/tests/test_reverse_proxy.sh @@ -1008,6 +1008,94 @@ function test_forwarded_unix() { start_paivana "$dest" } +function test_forwarded_rfc7239() { + stop_paivana + start_paivana "$PAIVANA_DEST" -f + + msg "-f: RFC 7239 Forwarded is extended with our own element" + curl -sS -H 'Forwarded: for=203.0.113.7;proto=https;host=public.example.com' \ + -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + local fwd + fwd="$(upstream_header forwarded)" + case "$fwd" in + "for=203.0.113.7;proto=https;host=public.example.com, for=127.0.0.1;by=_paivana;"*) ;; + *) fail "Forwarded not extended correctly: '$fwd'";; + esac + ok + + # A proxy that speaks only RFC 7239 must still be understood by an + # origin that speaks only X-Forwarded-*, or we would report the + # proxy as the client and the wrong scheme with it. + msg "-f: Forwarded is mirrored into the X-Forwarded-* headers" + local xff proto host + xff="$(upstream_header x-forwarded-for)" + proto="$(upstream_header x-forwarded-proto)" + host="$(upstream_header x-forwarded-host)" + [ "$xff" = "203.0.113.7, 127.0.0.1" ] || \ + fail "X-Forwarded-For='$xff', want '203.0.113.7, 127.0.0.1'" + [ "$proto" = "https" ] || \ + fail "X-Forwarded-Proto='$proto', want 'https' (Forwarded said so)" + [ "$host" = "public.example.com" ] || \ + fail "X-Forwarded-Host='$host', want 'public.example.com'" + ok + + # RFC 7239 §6.3: "unknown" is a legal node identifier, but + # X-Forwarded-For has no way to say it -- so no chain is + # synthesized rather than one with a hop silently missing. + msg "-f: a Forwarded chain that X-Forwarded-For cannot express is not faked" + curl -sS -H 'Forwarded: for=unknown' \ + -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 just our own peer '127.0.0.1'" + fwd="$(upstream_header forwarded)" + case "$fwd" in + "for=unknown, for=127.0.0.1;by=_paivana;"*) ;; + *) fail "Forwarded should still carry the unknown hop: '$fwd'";; + esac + ok + + msg "no -f: a client's Forwarded is replaced, not extended" + stop_paivana + start_paivana "$PAIVANA_DEST" + curl -sS -H 'Forwarded: for=1.2.3.4;proto=https' \ + -o "$TMPDIR/body" "$(PAIVANA_URL /echo-headers)" 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + fwd="$(upstream_header forwarded)" + case "$fwd" in + *1.2.3.4*) fail "client's Forwarded element survived: '$fwd'";; + esac + case "$fwd" in + "for=127.0.0.1;by=_paivana;proto=http;"*) ;; + *) fail "unexpected Forwarded: '$fwd'";; + esac + ok +} + +function test_forwarded_unix_rfc7239() { + # Unlike X-Forwarded-For, RFC 7239 has a spelling for a hop with no + # address (§6.3 "unknown"), so the Unix hop need not go unrecorded. + msg "unix socket: our Forwarded element says for=unknown" + local dest="$PAIVANA_DEST" + stop_paivana + start_paivana_unix "$dest" -f + curl -sS --unix-socket "$PAIVANA_SOCK" \ + -H 'Forwarded: for=203.0.113.7' \ + -o "$TMPDIR/body" http://localhost/echo-headers 2>"$TMPDIR/err" \ + || fail "curl: $(cat "$TMPDIR/err")" + local fwd + fwd="$(upstream_header forwarded)" + case "$fwd" in + "for=203.0.113.7, for=unknown;by=_paivana;"*) ;; + *) fail "unexpected Forwarded over a Unix socket: '$fwd'";; + esac + ok + stop_paivana + start_paivana "$dest" +} + ###################################################################### # TRUSTED_PROXIES configuration validation. # @@ -1108,6 +1196,8 @@ test_pipelined test_forwarded_no_flag test_forwarded_with_flag test_forwarded_unix +test_forwarded_rfc7239 +test_forwarded_unix_rfc7239 test_trusted_proxies_config stop_paivana