NAME

Punk::Observe::Target - where a webhook is allowed to point

SYNOPSIS

use Punk::Observe::Target;

my $r = Punk::Observe::Target::check($url, \@allowlist);
die "refused: $r->{reason}\n" unless $r->{ok};

DESCRIPTION

A webhook URL is attacker-influenced input, and fetching it is an SSRF: the server makes a request to an address a user typed into a form.

On a cloud instance that request comes from inside the perimeter. The classic target is the metadata service on 169.254.169.254, which hands credentials to anything asking from the right place; loopback reaches whatever else is bound on the box, including this process.

So the destination is validated before the request and the default is refusal: loopback, link-local, the private ranges, and anything that is not plain http or https.

What it does not claim

This is not a complete SSRF defence. A hostname that resolves to a private address defeats any check made on a string, which is why the same policy must also be applied to the address at connect time by the caller.

What this module does is refuse the literal cases - which is most of them - and refuse them where an operator can see the reason.

A few names are refused as well as addresses, because http://localhost:5432/ never reaches an address check that is only applied to literals: localhost, anything under .localhost, .local, .internal and metadata.google.internal.

Userinfo cannot disguise a host. http://hooks.example.com@127.0.0.1/ is loopback, and a parser that stops at the first delimiter reads the decoy.

The allowlist is exhaustive

An operator who has decided an internal host is a legitimate webhook target says so explicitly. Once an allowlist exists, nothing outside it is permitted - falling through to the default ranges would make it a suggestion rather than a policy.

An entry matches a whole host or a dot-anchored suffix. The anchor matters: an entry of slack.com must not admit slack.com.attacker.net, which a plain suffix compare does.

The allowlist never overrides the scheme. file:// and gopher:// are not webhook destinations under any policy.

FUNCTIONS

check

my $r = Punk::Observe::Target::check($url, $allowlist_or_undef);

Returns { ok, code, reason }. reason is a sentence fit to show an operator, since the point of refusing is that somebody can tell why.

SEE ALSO

Punk::Observe, Punk::Observe::Route