Compare commits

..

2 Commits

Author SHA1 Message Date
72013a9a08 test: add DNS cache coverage 2025-12-29 16:11:31 +01:00
e6fc9c919f feat: add DNS cache and subprocess resolution 2025-12-29 16:11:24 +01:00
2 changed files with 104 additions and 16 deletions

View File

@@ -4,6 +4,7 @@ use Mojo::Base -base;
use Mojo::URL; use Mojo::URL;
use Mojo::UserAgent; use Mojo::UserAgent;
use Mojo::Promise; use Mojo::Promise;
use Mojo::IOLoop;
use Urupam::Utils qw(sanitize_url); use Urupam::Utils qw(sanitize_url);
use Socket use Socket
qw(getaddrinfo getnameinfo NI_NUMERICHOST NI_NUMERICSERV AF_INET AF_INET6 SOCK_STREAM); qw(getaddrinfo getnameinfo NI_NUMERICHOST NI_NUMERICSERV AF_INET AF_INET6 SOCK_STREAM);
@@ -25,6 +26,9 @@ my @BLOCKED_DOMAINS = qw(
localhost 127.0.0.1 0.0.0.0 ::1 localhost 127.0.0.1 0.0.0.0 ::1
); );
my $DNS_CACHE_TTL = 300;
my %dns_cache;
has ua => sub { has ua => sub {
my $self = shift; my $self = shift;
Mojo::UserAgent->new( Mojo::UserAgent->new(
@@ -146,25 +150,64 @@ sub _resolve_host {
[ { type => 'ipv6', ip => $ipv6_host } ] ); [ { type => 'ipv6', ip => $ipv6_host } ] );
} }
my ( $err, @results ) = my $cache_key = lc($host);
getaddrinfo( $host, undef, { socktype => SOCK_STREAM } ); my $now = time();
return Mojo::Promise->resolve( [] ) if $err; if ( exists $dns_cache{$cache_key} ) {
my $cached = $dns_cache{$cache_key};
my @addresses; if ( $now < $cached->{expires} ) {
for my $res (@results) { return Mojo::Promise->resolve( $cached->{addresses} );
my ( $hostnum, undef ) =
getnameinfo( $res->{addr}, NI_NUMERICHOST | NI_NUMERICSERV );
next unless defined $hostnum && length $hostnum;
if ( $res->{family} == AF_INET ) {
push @addresses, { type => 'ipv4', ip => $hostnum };
}
elsif ( $res->{family} == AF_INET6 ) {
push @addresses, { type => 'ipv6', ip => $hostnum };
} }
delete $dns_cache{$cache_key};
} }
return Mojo::Promise->resolve( \@addresses ); my $promise = Mojo::Promise->new;
Mojo::IOLoop->subprocess(
sub {
my ($hostname) = @_;
my ( $err, @results ) =
getaddrinfo( $hostname, undef, { socktype => SOCK_STREAM } );
return { error => $err, results => \@results };
},
sub {
my ( $subprocess, $err, $data ) = @_;
if ($err) {
$promise->resolve( [] );
return;
}
my $res = $data;
if ( $res->{error} ) {
$promise->resolve( [] );
return;
}
my @addresses;
for my $result ( @{ $res->{results} } ) {
my ( $hostnum, undef ) =
getnameinfo( $result->{addr},
NI_NUMERICHOST | NI_NUMERICSERV );
next unless defined $hostnum && length $hostnum;
if ( $result->{family} == AF_INET ) {
push @addresses, { type => 'ipv4', ip => $hostnum };
}
elsif ( $result->{family} == AF_INET6 ) {
push @addresses, { type => 'ipv6', ip => $hostnum };
}
}
my $addresses_ref = \@addresses;
$dns_cache{$cache_key} = {
addresses => $addresses_ref,
expires => $now + $DNS_CACHE_TTL
};
$promise->resolve($addresses_ref);
},
$host
);
return $promise;
} }
sub is_blocked_url { sub is_blocked_url {

View File

@@ -2,6 +2,7 @@ use Test::More;
use Test::MockObject; use Test::MockObject;
use Mojo::Promise; use Mojo::Promise;
use Urupam::Validation; use Urupam::Validation;
use Socket qw(AF_INET);
use_ok('Urupam::Validation'); use_ok('Urupam::Validation');
@@ -80,6 +81,18 @@ sub with_ssrf_ua {
return $code->(); return $code->();
} }
sub with_subprocess_stub {
my ( $result, $code, $calls_ref ) = @_;
no warnings 'redefine';
local *Mojo::IOLoop::subprocess = sub {
my ( $class, $work, $finish, $host ) = @_;
$$calls_ref++ if defined $calls_ref;
$finish->( undef, undef, $result );
return;
};
return $code->();
}
subtest 'is_valid_url_length' => sub { subtest 'is_valid_url_length' => sub {
ok( $validator->is_valid_url_length('http://example.com'), ok( $validator->is_valid_url_length('http://example.com'),
'valid URL length passes' ); 'valid URL length passes' );
@@ -271,6 +284,38 @@ subtest 'is_blocked_url' => sub {
); );
}; };
subtest '_resolve_host - caches results' => sub {
my $calls = 0;
my $result = {
error => 0,
results => [ { addr => '127.0.0.1', family => AF_INET } ],
};
with_subprocess_stub(
$result,
sub {
my ( $value, $error ) =
wait_promise( $validator->_resolve_host('example.com') );
is( $error, undef, 'first resolve has no error' );
is( scalar @$value, 1, 'first resolve returns one address' );
},
\$calls
);
with_subprocess_stub(
$result,
sub {
my ( $value, $error ) =
wait_promise( $validator->_resolve_host('example.com') );
is( $error, undef, 'cached resolve has no error' );
is( scalar @$value, 1, 'cached resolve returns one address' );
},
\$calls
);
is( $calls, 1, 'subprocess called once due to cache' );
};
subtest 'validate_short_code' => sub { subtest 'validate_short_code' => sub {
my @valid = ( my @valid = (
[ 'abc123456789', 'alphanumeric code passes' ], [ 'abc123456789', 'alphanumeric code passes' ],