Developer-Dashboard
view release on metacpan or search on metacpan
t/57-hunt-auth.t view on Meta::CPAN
use strict;
use warnings FATAL => 'all';
use Digest::SHA qw(sha256_hex hmac_sha256);
use File::Spec;
use File::Temp qw(tempdir);
use Socket qw(AF_INET pack_sockaddr_in inet_aton);
use Test::More;
use lib 'lib';
use Developer::Dashboard::Auth;
use Developer::Dashboard::FileRegistry;
use Developer::Dashboard::JSON qw(json_encode);
use Developer::Dashboard::PathRegistry;
# reference_pbkdf2($password, $salt, $iterations)
# Independent PBKDF2-HMAC-SHA256 reference (single output block) used to
# cross-check the module implementation against RFC test vectors.
# Input: password string, salt string, iteration count.
# Output: 64-character lowercase hex derived key.
sub reference_pbkdf2 {
my ( $password, $salt, $iterations ) = @_;
my $u = hmac_sha256( $salt . pack( 'N', 1 ), $password );
my $out = $u;
for ( 2 .. $iterations ) {
$u = hmac_sha256( $u, $password );
$out ^= $u;
}
return unpack( 'H*', $out );
}
my $home = tempdir( CLEANUP => 1 );
local $ENV{HOME} = $home;
local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
local $ENV{DEVELOPER_DASHBOARD_CHECKERS};
my $paths = Developer::Dashboard::PathRegistry->new( home => $home );
my $files = Developer::Dashboard::FileRegistry->new( paths => $paths );
my $auth = Developer::Dashboard::Auth->new( files => $files, paths => $paths );
# ---------------------------------------------------------------------------
# Finding (2): helper passwords must be stretched with a work factor, not a
# single unstretched SHA-256, while pre-existing SHA-256 records keep working.
# ---------------------------------------------------------------------------
# The module's PBKDF2 helper must match published PBKDF2-HMAC-SHA256 vectors,
# proving the stretching primitive is correct and not a bespoke miscalculation.
is(
Developer::Dashboard::Auth::_pbkdf2_hmac_sha256_hex( 'password', 'salt', 1 ),
'120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b',
'pbkdf2 matches the RFC vector for one iteration',
);
is(
Developer::Dashboard::Auth::_pbkdf2_hmac_sha256_hex( 'password', 'salt', 2 ),
'ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43',
'pbkdf2 matches the RFC vector for two iterations',
);
is(
Developer::Dashboard::Auth::_pbkdf2_hmac_sha256_hex( 'password', 'salt', 4096 ),
reference_pbkdf2( 'password', 'salt', 4096 ),
'pbkdf2 agrees with an independent reference at a higher work factor',
);
my $username = 'stretchuser';
my $password = 'helper-pass-123';
my $record = $auth->add_user( username => $username, password => $password );
is( $record->{password_scheme}, 'pbkdf2-hmac-sha256', 'add_user records the stretched password scheme' );
cmp_ok( $record->{iterations}, '>=', 200_000, 'add_user records a strong PBKDF2 work factor' );
is( length( $record->{password_hash} ), 64, 'stored password hash is a 32-byte derived key in hex' );
my $unstretched = sha256_hex( join ':', $record->{salt}, $username, $password );
isnt(
$record->{password_hash},
$unstretched,
'stored password hash is stretched, not a single-round salted SHA-256',
);
is(
$record->{password_hash},
reference_pbkdf2( $password, $record->{salt}, $record->{iterations} ),
'stored password hash is exactly the PBKDF2 derivation of the password',
);
ok( $auth->verify_user( username => $username, password => $password ), 'correct password verifies against a stretched record' );
ok( !$auth->verify_user( username => $username, password => 'wrong-password' ), 'wrong password is rejected for a stretched record' );
# Backward compatibility: a helper record written before stretching existed has
# no scheme label and a single-round SHA-256 hash. It must still verify so an
# upgrade never locks established helper users out of their own dashboard.
my $legacy_user = 'legacyhelper';
my $legacy_salt = 'legacy-fixed-salt';
my $legacy_pass = 'legacy-pass-123';
my $legacy_record = {
username => $legacy_user,
role => 'helper',
salt => $legacy_salt,
password_hash => sha256_hex( join ':', $legacy_salt, $legacy_user, $legacy_pass ),
updated_at => '2026-01-01T00:00:00Z',
};
my $legacy_file = File::Spec->catfile( $paths->users_root, "$legacy_user.json" );
open my $lfh, '>:raw', $legacy_file or die "Unable to write $legacy_file: $!";
print {$lfh} json_encode($legacy_record);
close $lfh;
ok( $auth->verify_user( username => $legacy_user, password => $legacy_pass ), 'legacy single-round SHA-256 records still verify (no lockout on upgrade)' );
ok( !$auth->verify_user( username => $legacy_user, password => 'nope' ), 'legacy records still reject wrong passwords' );
# The constant-time comparison must behave like equality across the edge cases
# that matter for hash checking without leaking match progress via early exit.
ok( Developer::Dashboard::Auth::_secure_compare( 'abc123', 'abc123' ), 'secure compare accepts identical strings' );
ok( !Developer::Dashboard::Auth::_secure_compare( 'abc123', 'abc124' ), 'secure compare rejects equal-length differing strings' );
ok( !Developer::Dashboard::Auth::_secure_compare( 'abc', 'abcd' ), 'secure compare rejects length mismatches' );
ok( !Developer::Dashboard::Auth::_secure_compare( undef, 'abc' ), 'secure compare rejects an undefined operand' );
# ---------------------------------------------------------------------------
# Finding (1): DNS-rebinding admin-trust â FIXED. Arbitrary hostnames that
# merely resolve to loopback are no longer trusted as admin. Well-known
# local machine aliases (localhost, localhost.localdomain) are still
# accepted without DNS resolution.
# ---------------------------------------------------------------------------
is(
$auth->trust_tier( remote_addr => '127.0.0.1', host => '127.0.0.1:7890' ),
'admin',
'literal loopback host over a loopback connection stays admin',
);
is(
$auth->trust_tier( remote_addr => '10.0.0.9', host => '127.0.0.1:7890' ),
'helper',
'a non-loopback client never gains admin regardless of the host header',
);
is(
$auth->trust_tier(
remote_addr => '127.0.0.1',
host => 'dashboard-alias.example:7890',
extra_loopback_hosts => ['dashboard-alias.example'],
),
'admin',
'an explicitly configured local alias host stays admin',
);
( run in 0.533 second using v1.01-cache-2.11-cpan-4ab04211f4c )