Punk

 view release on metacpan or  search on metacpan

include/punk/punk_proxy.h  view on Meta::CPAN

 * address without any of them being changed. The mount path already rewrites
 * env keys in place (punk_serve.h), so this is an established move.
 *
 * Why it exists: rate_limit keys on REMOTE_ADDR, and because Hyperman's
 * arena makes a counter exact across the whole worker pool rather than per
 * worker, a limiter behind a proxy puts EVERY client in one bucket - a
 * 100/min rule throttles the whole site at 100/min. block_ip, keyed the same
 * way, bans the load balancer.
 *
 * The hop rule, which is what almost every implementation gets wrong:
 * X-Forwarded-For reads `client, proxy1, proxy2` and each hop APPENDS the
 * address it received the connection FROM. The socket peer is the last
 * proxy and never appears in the header it forwarded. So with N trusted
 * proxies the client sits at index N-1 counting from the RIGHT. Taking the
 * leftmost entry is the spoofable version: the client writes that one.
 *
 * Everything here is prefixed pp_ / PP_. Needs nothing but punk_compat.h;
 * included before punk_serve.h, which calls pp_resolve.
 */

#include <string.h>

include/punk/punk_proxy.h  view on Meta::CPAN

            p->ncidrs = (int)n;
            p->hops = 0;
        }
        else if (!SvROK(*t) && !looks_like_number(*t)) {
            STRLEN sl;
            const char *s = SvPV_const(*t, sl);
            if (sl == 3 && memEQ(s, "all", 3)) {
                if (!(appenv && strEQ(appenv, "development"))) {
                    pp_free(aTHX_ p);
                    croak("Punk: proxy: trust => 'all' believes any "
                          "X-Forwarded-For from anyone, so with no proxy in "
                          "front it is a total bypass - it is allowed only "
                          "under PUNK_ENV=development. Name a hop count or "
                          "the proxy networks instead");
                }
                p->hops = -1;
            } else {
                SV *msg = sv_2mortal(newSVpvf(
                    "Punk: proxy: trust must be a hop count, an arrayref of "
                    "CIDRs, or 'all' - not '%.*s'", (int)sl, s));
                pp_free(aTHX_ p);

include/punk/punk_proxy.h  view on Meta::CPAN

            if (n < 1 || n > PP_MAX_HOPS) {
                pp_free(aTHX_ p);
                croak("Punk: proxy: trust => %" IVdf " is not a usable hop "
                      "count (1..%d)", n, PP_MAX_HOPS);
            }
            p->hops = (int)n;
        }
    }

    p->for_key   = pp_key_opt(aTHX_ cfg, "for_header",
                              "X-Forwarded-For",   &p->for_len);
    p->proto_key = pp_key_opt(aTHX_ cfg, "proto_header",
                              "X-Forwarded-Proto", &p->proto_len);
    p->host_key  = pp_key_opt(aTHX_ cfg, "host_header",
                              "X-Forwarded-Host",  &p->host_len);
    p->port_key  = pp_key_opt(aTHX_ cfg, "port_header",
                              "X-Forwarded-Port",  &p->port_len);
    return p;
}

/* ---- the per-request resolution ----------------------------------------- */

lib/Punk.pm  view on Meta::CPAN

C<X-Forwarded-Proto> sets C<psgi.url_scheme> (and C<HTTPS>),
C<X-Forwarded-Host> sets C<HTTP_HOST>, and C<X-Forwarded-Port> sets
C<SERVER_PORT>, all under the same trust decision.

B<Without this keyword, a limiter behind a proxy is not just approximate -
it is a site-wide outage waiting to happen.> See L</The shared bucket>
below.

=head3 How C<trust> counts

C<X-Forwarded-For> reads C<< client, proxy1, proxy2 >>, and each hop
B<appends> the address it received the connection I<from>. The socket peer
is the last proxy and never appears in the header it forwarded. So with
C<< trust => N >> the client sits at index C<N-1> counting from the
B<right>.

Counting from the left is the spoofable version, because the leftmost entry
is the one the client writes. With one proxy in front and a client sending
C<< X-Forwarded-For: 9.9.9.9 >>, the header arriving here is
C<< 9.9.9.9, <real client> >> - and Punk answers with the real client.

A chain shorter than C<trust> declares is a misconfiguration, or a client
that sent nothing; the answer is then the socket peer, never the leftmost
entry. An entry that is not a valid address ends the walk the same way -
C<REMOTE_ADDR> feeds a shared-memory rate-limit key, so attacker-controlled
bytes must never reach it.

C<< trust => \@cidrs >> walks right to left while each entry is one of the
named networks and takes the first one that is not, having first checked

lib/Punk.pm  view on Meta::CPAN


=head3 The shared bucket

C<rate_limit> keys on C<REMOTE_ADDR>, and because the counters live in
L<Hyperman>'s shared arena a limit is B<exact across the whole worker pool>
rather than per worker. Behind a proxy without this keyword, C<REMOTE_ADDR>
is the proxy for every request, so every client on the internet shares one
bucket and a C<< limit => 100 >> rule throttles the entire site at 100 per
window. C<< $c->block_ip >>, keyed the same way, bans the load balancer.

Reaching for C<< by => 'header:X-Forwarded-For' >> instead is worse, not
better: nothing validates the header, so on an application that is I<not>
behind a proxy any client can set it and step into a fresh bucket at will.

=head3 What this does not fix

L<Hyperman>'s edge denylist drops a connection at C<accept>, before a byte
is read, so it cannot see a header and never will. Behind a proxy it can
only ever match the proxy's own address. C<< $c->block_ip($client) >> still
writes to the arena, but the ban takes effect at dispatch as a C<403>
rather than at the edge - the same outcome, at the cost of a request.

lib/Punk/RateLimit.pm  view on Meta::CPAN

proxy is the I<proxy's> address on every request, and because the counter is
shared across the whole worker pool rather than per worker, every client on
the internet lands in B<one bucket>: a C<< limit =E<gt> 100 >> rule then
throttles the entire site at 100 per window, and C<< $c-E<gt>block_ip >>
bans the load balancer.

    use Punk;
    proxy;                                  # one proxy in front
    rate_limit limit => 100, window => 60;   # now keyed on the real client

Do not reach for C<< by =E<gt> 'header:X-Forwarded-For' >> instead. Nothing
validates that header, so on an application that is not actually behind a
proxy any client can set it and step into a fresh bucket at will - a bypass
in place of a shared bucket. L<Punk/proxy> validates the hop chain; this
does not.

Note also that once a proxy is in front, C<< $c-E<gt>block_ip >> can no
longer be enforced at C<accept>: the edge sees only the proxy, so the ban
becomes a C<403> at dispatch. Same outcome, one request's worth of cost.

=head1 SEE ALSO

t/1210-proxy-parse.t  view on Meta::CPAN

use 5.010;
use strict;
use warnings;
use Test::More;
use Punk ();

# The hop walk alone (pp_xff_client, punk_proxy.h) through the Punk::Proxy
# author shim. This is the part every implementation gets wrong, so it is
# tested as a table before anything is wired to a request.
#
# The rule: X-Forwarded-For reads `client, proxy1, proxy2` and each hop
# APPENDS the address it received FROM. The socket peer is the last proxy and
# never appears in the header it forwarded. With N trusted proxies the client
# is at index N-1 counting from the RIGHT. Reaching for the leftmost entry is
# the spoofable version, because the client writes that one.

sub client { Punk::Proxy::_client($_[0], $_[1], $_[2]) }

# ---- fixed hop counts ------------------------------------------------------

is client('1.2.3.4', '10.0.0.1', 1), '1.2.3.4',
   'one proxy, one entry: that entry is the client';

is client('1.2.3.4, 10.0.0.7', '10.0.0.1', 2), '1.2.3.4',
   'two proxies, two entries: the leftmost is the client';

is client('1.2.3.4, 10.0.0.7, 10.0.0.8', '10.0.0.1', 3), '1.2.3.4',
   'three proxies, three entries';

# THE SPOOF CASE. One real proxy in front. The client sends its own
# X-Forwarded-For claiming to be 9.9.9.9; the proxy appends the address it
# actually saw. Counting from the right lands on the truth; counting from the
# left would hand the attacker any address it liked - including one that is
# on somebody else's rate-limit bucket, or off a denylist.
is client('9.9.9.9, 1.2.3.4', '10.0.0.1', 1), '1.2.3.4',
   'a forged leading entry is ignored: the hop count counts from the right';

is client('7.7.7.7, 9.9.9.9, 1.2.3.4', '10.0.0.1', 1), '1.2.3.4',
   'a whole forged chain is ignored';

is client('9.9.9.9, 1.2.3.4, 10.0.0.7', '10.0.0.1', 2), '1.2.3.4',

t/1211-proxy.t  view on Meta::CPAN

    use Punk;
    proxy trust => 1, for_header => 'CF-Connecting-IP';
    get '/env' => \&main::echo;
}
{
    my $call = caller_for(Custom->to_app);
    my $d = body_of($call->(hdr => 'CF_CONNECTING_IP', hval => '1.2.3.4',
                            xff => '9.9.9.9'));
    is $d->{remote_addr}, '1.2.3.4', 'for_header names a different header';
    isnt $d->{remote_addr}, '9.9.9.9',
       'and the standard X-Forwarded-For is not consulted once it is renamed';
}

# ---- CIDR trust ------------------------------------------------------------

{
    package Cidr;
    use Punk;
    proxy trust => ['10.0.0.0/8'];
    get '/env' => \&main::echo;
}



( run in 2.847 seconds using v1.01-cache-2.11-cpan-54e63673c56 )