Compare commits
2 Commits
eb4c4e4c4c
...
e9c298110d
| Author | SHA1 | Date | |
|---|---|---|---|
| e9c298110d | |||
| ae1dab8116 |
@@ -4,6 +4,7 @@ use Mojo::Base -base;
|
||||
use Mojo::URL;
|
||||
use Mojo::UserAgent;
|
||||
use Mojo::Promise;
|
||||
use Mojo::IOLoop;
|
||||
use Urupam::Utils qw(sanitize_url);
|
||||
|
||||
my $MAX_URL_LENGTH = 2048;
|
||||
@@ -95,34 +96,148 @@ sub _is_private_ipv6 {
|
||||
my ( $self, $ip ) = @_;
|
||||
$ip = lc($ip);
|
||||
$ip =~ s/^\[|\]$//g;
|
||||
return
|
||||
$ip eq '::1'
|
||||
|| $ip eq '::'
|
||||
|| $ip =~ /^::ffff:(127\.|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/;
|
||||
|
||||
return 1 if $ip eq '::1';
|
||||
return 1 if $ip eq '::';
|
||||
return 1
|
||||
if $ip =~ /^::ffff:(127\.|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/;
|
||||
|
||||
if ( $ip =~ /^([0-9a-f]{0,4}:)+[0-9a-f]{0,4}$/ || $ip =~ /^::/ ) {
|
||||
my @parts = split /:/, $ip;
|
||||
my $first_part = $parts[0] || '';
|
||||
|
||||
if ( length($first_part) > 0 ) {
|
||||
my $first = hex($first_part);
|
||||
if ( $first >= 0xfc && $first <= 0xfd ) {
|
||||
return 1;
|
||||
}
|
||||
if ( $first == 0xfe && @parts > 1 ) {
|
||||
my $second_part = $parts[1] || '';
|
||||
if ( length($second_part) > 0 ) {
|
||||
my $second = hex($second_part);
|
||||
if ( $second >= 0x80 && $second <= 0xbf ) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $ip =~ /^fc[0-9a-f]{2}:/i || $ip =~ /^fd[0-9a-f]{2}:/i ) {
|
||||
return 1;
|
||||
}
|
||||
if ( $ip =~ /^fe[89ab][0-9a-f]:/i ) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
sub _resolve_host {
|
||||
my ( $self, $host ) = @_;
|
||||
return Mojo::Promise->resolve( [] )
|
||||
unless defined $host && length($host) > 0;
|
||||
|
||||
if ( $self->_is_valid_ipv4($host) ) {
|
||||
return Mojo::Promise->resolve( [ { type => 'ipv4', ip => $host } ] );
|
||||
}
|
||||
|
||||
my $ipv6_host = $host;
|
||||
$ipv6_host =~ s/^\[|\]$//g;
|
||||
if ( $self->_is_private_ipv6($ipv6_host) ) {
|
||||
return Mojo::Promise->resolve(
|
||||
[ { type => 'ipv6', ip => $ipv6_host } ] );
|
||||
}
|
||||
|
||||
if ( $ipv6_host =~ /^([0-9a-f]{0,4}:){2,}[0-9a-f]{0,4}$/i
|
||||
|| $ipv6_host =~ /^::/ )
|
||||
{
|
||||
return Mojo::Promise->resolve(
|
||||
[ { type => 'ipv6', ip => $ipv6_host } ] );
|
||||
}
|
||||
|
||||
my $loop = Mojo::IOLoop->singleton;
|
||||
my $resolver = $loop->resolver;
|
||||
my @addresses;
|
||||
|
||||
my $promise = Mojo::Promise->new;
|
||||
my $pending = 2;
|
||||
my $done = sub {
|
||||
$pending--;
|
||||
if ( $pending == 0 ) {
|
||||
$promise->resolve( \@addresses );
|
||||
}
|
||||
};
|
||||
|
||||
$resolver->resolve(
|
||||
$host => sub {
|
||||
my ( $resolver, $err, @results ) = @_;
|
||||
if ( !$err && @results ) {
|
||||
for my $result (@results) {
|
||||
push @addresses,
|
||||
{ type => 'ipv4', ip => $result->{address} };
|
||||
}
|
||||
}
|
||||
$done->();
|
||||
}
|
||||
);
|
||||
|
||||
$resolver->resolve(
|
||||
$host => { type => 'AAAA' } => sub {
|
||||
my ( $resolver, $err, @results ) = @_;
|
||||
if ( !$err && @results ) {
|
||||
for my $result (@results) {
|
||||
push @addresses,
|
||||
{ type => 'ipv6', ip => $result->{address} };
|
||||
}
|
||||
}
|
||||
$done->();
|
||||
}
|
||||
);
|
||||
|
||||
return $promise;
|
||||
}
|
||||
|
||||
sub is_blocked_url {
|
||||
my ( $self, $url ) = @_;
|
||||
return 0 unless defined $url;
|
||||
return Mojo::Promise->resolve(0) unless defined $url;
|
||||
|
||||
my $parsed = $self->_parse_url($url);
|
||||
return 0 unless $parsed;
|
||||
return Mojo::Promise->resolve(0) unless $parsed;
|
||||
|
||||
my $host = lc( $parsed->host // '' );
|
||||
return Mojo::Promise->resolve(0) unless length($host) > 0;
|
||||
|
||||
for my $blocked (@BLOCKED_DOMAINS) {
|
||||
return 1 if $host eq $blocked;
|
||||
return Mojo::Promise->resolve(1) if $host eq $blocked;
|
||||
}
|
||||
|
||||
if ( $self->_is_private_ipv4($host) ) {
|
||||
return 1;
|
||||
return Mojo::Promise->resolve(1);
|
||||
}
|
||||
|
||||
if ( $self->_is_private_ipv6($host) ) {
|
||||
return 1;
|
||||
return Mojo::Promise->resolve(1);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return $self->_resolve_host($host)->then(
|
||||
sub {
|
||||
my $addresses = shift;
|
||||
for my $addr (@$addresses) {
|
||||
if ( $addr->{type} eq 'ipv4'
|
||||
&& $self->_is_private_ipv4( $addr->{ip} ) )
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
if ( $addr->{type} eq 'ipv6'
|
||||
&& $self->_is_private_ipv6( $addr->{ip} ) )
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
sub check_url_reachable {
|
||||
@@ -182,7 +297,7 @@ sub check_ssl_certificate {
|
||||
|
||||
sub validate_short_code {
|
||||
my ( $self, $code ) = @_;
|
||||
return defined $code && length($code) > 0 && $code =~ /^[0-9a-zA-Z\-_]+$/;
|
||||
return defined $code && length($code) == 12 && $code =~ /^[0-9a-zA-Z\-_]+$/;
|
||||
}
|
||||
|
||||
sub validate_url_with_checks {
|
||||
@@ -204,18 +319,24 @@ sub validate_url_with_checks {
|
||||
return Mojo::Promise->reject(
|
||||
"URL exceeds maximum length of $MAX_URL_LENGTH characters")
|
||||
unless $self->is_valid_url_length($sanitized);
|
||||
return Mojo::Promise->reject(
|
||||
'This URL cannot be shortened (blocked domain or local address)')
|
||||
if $self->is_blocked_url($sanitized);
|
||||
|
||||
my $ssl_check =
|
||||
$parsed->scheme eq 'https'
|
||||
? $self->check_ssl_certificate($sanitized)
|
||||
: Mojo::Promise->resolve(1);
|
||||
return $self->is_blocked_url($sanitized)->then(
|
||||
sub {
|
||||
my $blocked = shift;
|
||||
return Mojo::Promise->reject(
|
||||
'This URL cannot be shortened (blocked domain or local address)'
|
||||
) if $blocked;
|
||||
|
||||
return $ssl_check->then(
|
||||
sub { return $self->check_url_reachable($sanitized); } )
|
||||
->then( sub { return $sanitized; } );
|
||||
my $ssl_check =
|
||||
$parsed->scheme eq 'https'
|
||||
? $self->check_ssl_certificate($sanitized)
|
||||
: Mojo::Promise->resolve(1);
|
||||
|
||||
return $ssl_check->then(
|
||||
sub { return $self->check_url_reachable($sanitized); } )
|
||||
->then( sub { return $sanitized; } );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
1;
|
||||
|
||||
@@ -46,6 +46,15 @@ sub mock_ua_with_error {
|
||||
return $mock_ua;
|
||||
}
|
||||
|
||||
sub with_resolved_addresses {
|
||||
my ( $addresses, $code ) = @_;
|
||||
no warnings 'redefine';
|
||||
local *Urupam::Validation::_resolve_host = sub {
|
||||
return Mojo::Promise->resolve($addresses);
|
||||
};
|
||||
return $code->();
|
||||
}
|
||||
|
||||
subtest 'is_valid_url_length' => sub {
|
||||
ok( $validator->is_valid_url_length('http://example.com'),
|
||||
'valid URL length passes' );
|
||||
@@ -158,11 +167,20 @@ subtest '_is_private_ipv6' => sub {
|
||||
[ '::ffff:192.168.1.1', '::ffff:192.168.1.1 is private' ],
|
||||
[ '::ffff:10.0.0.1', '::ffff:10.0.0.1 is private' ],
|
||||
[ '::ffff:172.16.0.1', '::ffff:172.16.0.1 is private' ],
|
||||
[ 'fc00::1', 'fc00::/7 (unique local) is private' ],
|
||||
[ 'fcff::1', 'fc00::/7 (unique local) is private' ],
|
||||
[ 'fd00::1', 'fc00::/7 (unique local) is private' ],
|
||||
[ 'fdff::1', 'fc00::/7 (unique local) is private' ],
|
||||
[ 'fe80::1', 'fe80::/10 (link-local) is private' ],
|
||||
[ 'fe80::abcd', 'fe80::/10 (link-local) is private' ],
|
||||
[ 'febf::1', 'fe80::/10 (link-local) is private' ],
|
||||
);
|
||||
my @public = (
|
||||
[ '2001:db8::1', '2001:db8::1 is not private' ],
|
||||
[ '::ffff:8.8.8.8', '::ffff:8.8.8.8 is not private' ],
|
||||
[ 'invalid', 'invalid IPv6 is not private' ],
|
||||
[ 'fec0::1', 'fec0:: is not private (deprecated, but not blocked)' ],
|
||||
[ 'fec1::1', 'fec1:: is not private' ],
|
||||
[ 'invalid', 'invalid IPv6 is not private' ],
|
||||
);
|
||||
|
||||
for my $case (@private) {
|
||||
@@ -184,6 +202,10 @@ subtest 'is_blocked_url' => sub {
|
||||
[ 'http://192.168.1.1/path', '192.168.1.1 is blocked' ],
|
||||
[ 'http://10.0.0.1/path', '10.0.0.1 is blocked' ],
|
||||
[ 'http://172.16.0.1/path', '172.16.0.1 is blocked' ],
|
||||
[ 'http://[fc00::1]/path', 'fc00::/7 (unique local) is blocked' ],
|
||||
[ 'http://[fd00::1]/path', 'fc00::/7 (unique local) is blocked' ],
|
||||
[ 'http://[fe80::1]/path', 'fe80::/10 (link-local) is blocked' ],
|
||||
[ 'http://[febf::1]/path', 'fe80::/10 (link-local) is blocked' ],
|
||||
);
|
||||
my @allowed = (
|
||||
[ 'http://example.com/path', 'public domain is not blocked' ],
|
||||
@@ -192,29 +214,43 @@ subtest 'is_blocked_url' => sub {
|
||||
[ undef, 'undef is not blocked' ],
|
||||
);
|
||||
|
||||
for my $case (@blocked) {
|
||||
ok( $validator->is_blocked_url( $case->[0] ), $case->[1] );
|
||||
}
|
||||
with_resolved_addresses(
|
||||
[],
|
||||
sub {
|
||||
for my $case (@blocked) {
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->is_blocked_url( $case->[0] ) );
|
||||
ok( $result, $case->[1] );
|
||||
is( $error, undef, "no error for $case->[1]" );
|
||||
}
|
||||
|
||||
for my $case (@allowed) {
|
||||
ok( !$validator->is_blocked_url( $case->[0] ), $case->[1] );
|
||||
}
|
||||
for my $case (@allowed) {
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->is_blocked_url( $case->[0] ) );
|
||||
ok( !$result, $case->[1] );
|
||||
is( $error, undef, "no error for $case->[1]" );
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
subtest 'validate_short_code' => sub {
|
||||
my @valid = (
|
||||
[ 'abc123', 'alphanumeric code passes' ],
|
||||
[ 'ABC123', 'uppercase code passes' ],
|
||||
[ 'abc-123', 'code with dash passes' ],
|
||||
[ 'abc_123', 'code with underscore passes' ],
|
||||
[ 'a', 'single character passes' ],
|
||||
[ 'abc123456789', 'alphanumeric code passes' ],
|
||||
[ 'ABC123456789', 'uppercase code passes' ],
|
||||
[ 'ab-123456789', 'code with dash passes' ],
|
||||
[ 'ab_123456789', 'code with underscore passes' ],
|
||||
[ '0123456789ab', '12 character code passes' ],
|
||||
);
|
||||
my @invalid = (
|
||||
[ 'abc@123', 'code with @ fails' ],
|
||||
[ 'abc.123', 'code with dot fails' ],
|
||||
[ 'abc 123', 'code with space fails' ],
|
||||
[ '', 'empty code fails' ],
|
||||
[ undef, 'undef code fails' ],
|
||||
[ 'abc@12345678', 'code with @ fails' ],
|
||||
[ 'abc.12345678', 'code with dot fails' ],
|
||||
[ 'abc 12345678', 'code with space fails' ],
|
||||
[ 'abc123', 'code too short fails' ],
|
||||
[ 'a', 'single character fails' ],
|
||||
[ 'abc123456789012345', 'code too long fails' ],
|
||||
[ '', 'empty code fails' ],
|
||||
[ undef, 'undef code fails' ],
|
||||
);
|
||||
|
||||
for my $case (@valid) {
|
||||
@@ -438,8 +474,15 @@ subtest 'validate_url_with_checks - blocked IPv6 URL' => sub {
|
||||
|
||||
subtest 'validate_url_with_checks - HTTP success' => sub {
|
||||
$validator->ua( mock_ua_with_code(200) );
|
||||
my ( $result, $error ) = wait_promise(
|
||||
$validator->validate_url_with_checks('http://example.com/path') );
|
||||
my ( $result, $error );
|
||||
with_resolved_addresses(
|
||||
[],
|
||||
sub {
|
||||
( $result, $error ) = wait_promise(
|
||||
$validator->validate_url_with_checks('http://example.com/path')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
is( $result, 'http://example.com/path', 'valid HTTP URL passes' );
|
||||
is( $error, undef, 'valid HTTP URL has no error' );
|
||||
@@ -447,8 +490,16 @@ subtest 'validate_url_with_checks - HTTP success' => sub {
|
||||
|
||||
subtest 'validate_url_with_checks - HTTPS success' => sub {
|
||||
$validator->ua( mock_ua_with_code(200) );
|
||||
my ( $result, $error ) = wait_promise(
|
||||
$validator->validate_url_with_checks('https://example.com/path') );
|
||||
my ( $result, $error );
|
||||
with_resolved_addresses(
|
||||
[],
|
||||
sub {
|
||||
( $result, $error ) = wait_promise(
|
||||
$validator->validate_url_with_checks(
|
||||
'https://example.com/path')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
is( $result, 'https://example.com/path', 'valid HTTPS URL passes' );
|
||||
is( $error, undef, 'valid HTTPS URL has no error' );
|
||||
@@ -456,8 +507,14 @@ subtest 'validate_url_with_checks - HTTPS success' => sub {
|
||||
|
||||
subtest 'validate_url_with_checks - URL sanitization' => sub {
|
||||
$validator->ua( mock_ua_with_code(200) );
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->validate_url_with_checks('example.com/path') );
|
||||
my ( $result, $error );
|
||||
with_resolved_addresses(
|
||||
[],
|
||||
sub {
|
||||
( $result, $error ) = wait_promise(
|
||||
$validator->validate_url_with_checks('example.com/path') );
|
||||
}
|
||||
);
|
||||
|
||||
is( $result, 'http://example.com/path', 'URL is sanitized' );
|
||||
is( $error, undef, 'URL sanitization has no error' );
|
||||
@@ -465,9 +522,14 @@ subtest 'validate_url_with_checks - URL sanitization' => sub {
|
||||
|
||||
subtest 'validate_url_with_checks - SSL check failure' => sub {
|
||||
$validator->ua( mock_ua_with_error('SSL certificate verification failed') );
|
||||
my ( $result, $error ) =
|
||||
wait_promise(
|
||||
$validator->validate_url_with_checks('https://example.com') );
|
||||
my ( $result, $error );
|
||||
with_resolved_addresses(
|
||||
[],
|
||||
sub {
|
||||
( $result, $error ) = wait_promise(
|
||||
$validator->validate_url_with_checks('https://example.com') );
|
||||
}
|
||||
);
|
||||
|
||||
is( $result, undef, 'SSL check failure has no result' );
|
||||
like( $error, qr/Invalid SSL certificate/,
|
||||
@@ -494,9 +556,14 @@ subtest 'validate_url_with_checks - reachability check failure' => sub {
|
||||
|
||||
$validator->ua($mock_ua);
|
||||
|
||||
my ( $result, $error ) =
|
||||
wait_promise(
|
||||
$validator->validate_url_with_checks('https://example.com') );
|
||||
my ( $result, $error );
|
||||
with_resolved_addresses(
|
||||
[],
|
||||
sub {
|
||||
( $result, $error ) = wait_promise(
|
||||
$validator->validate_url_with_checks('https://example.com') );
|
||||
}
|
||||
);
|
||||
|
||||
is( $result, undef, 'reachability failure has no result' );
|
||||
like(
|
||||
|
||||
Reference in New Issue
Block a user