From 8a1ec939960cc0dbac10cd40faee3b29f4ffd960 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:18:57 +0100 Subject: [PATCH 1/2] security: add CSRF protection, XSS escaping, and security headers Add comprehensive security hardening to the NicTool web client: - CSRF protection: token-based validation on all state-changing operations (forms, delete links) with cookie + hidden field double-submit pattern - XSS output escaping: wrap all user-supplied values rendered in HTML with html_escape(), add js_escape() for JavaScript string contexts - Security headers: CSP, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy on all responses - Cookie hardening: HttpOnly, Secure, SameSite=Strict on session cookie - Add $res declaration to test files for strict mode compatibility Co-Authored-By: Claude Opus 4.6 (1M context) --- client/htdocs/delegate_zones.cgi | 56 +++-- client/htdocs/group.cgi | 35 ++-- client/htdocs/group_log.cgi | 20 +- client/htdocs/group_nameservers.cgi | 61 ++++-- client/htdocs/group_users.cgi | 40 +++- client/htdocs/group_zones.cgi | 56 +++-- client/htdocs/group_zones_log.cgi | 24 ++- client/htdocs/group_zones_query_log.cgi | 18 +- client/htdocs/help.cgi | 6 +- client/htdocs/index.cgi | 11 +- client/htdocs/move_nameservers.cgi | 8 +- client/htdocs/move_users.cgi | 8 +- client/htdocs/move_zones.cgi | 8 +- client/htdocs/nav.cgi | 6 +- client/htdocs/templates.cgi | 6 +- client/htdocs/user.cgi | 47 +++-- client/htdocs/zone.cgi | 163 +++++++++++---- client/htdocs/zone_record_log.cgi | 20 +- client/htdocs/zones.cgi | 12 +- client/lib/NicToolClient.pm | 266 +++++++++++++++++++----- client/templates/login.html | 3 +- server/t/08_nameservers.t | 2 +- server/t/10_zones.t | 2 +- 23 files changed, 646 insertions(+), 232 deletions(-) diff --git a/client/htdocs/delegate_zones.cgi b/client/htdocs/delegate_zones.cgi index 1b728ee8..ba77f0d9 100755 --- a/client/htdocs/delegate_zones.cgi +++ b/client/htdocs/delegate_zones.cgi @@ -33,7 +33,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -52,9 +56,18 @@ sub display { if ( $q->param('cancel_delegate') ) { # do nothing print qq[]; } - elsif ( $q->param('Save') ) { do_save( $nt_obj, $q, $user ); } - elsif ( $q->param('Modify') ) { do_modify( $nt_obj, $q, $user ); } - elsif ( $q->param('Remove') ) { do_remove( $nt_obj, $q, $user ); } + elsif ( $q->param('Save') ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + do_save( $nt_obj, $q, $user ); + } + elsif ( $q->param('Modify') ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + do_modify( $nt_obj, $q, $user ); + } + elsif ( $q->param('Remove') ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + do_remove( $nt_obj, $q, $user ); + } else { my $d = $q->param("delete") ? "delete" : ''; my $e = $q->param("edit") ? "edit" : $d; @@ -65,11 +78,12 @@ sub display { } sub display_record { - my ( $title, $zone, $zr ) = @_; + my ( $nt_obj, $title, $zone, $zr ) = @_; print qq[
$title
-
Record(s): - $zone->{'zone'} + @@ -84,13 +98,15 @@ sub display_record { - - - - - + + + + +
- record $zr->{'name'} + record ] + . $nt_obj->esc( $zr->{'name'} ) + . qq[ $zr->{'type'} $zr->{'address'} $zr->{'ttl'} $zr->{'weight'} $zr->{'description'} ] . $nt_obj->esc( $zr->{'type'} ) . qq[ ] . $nt_obj->esc( $zr->{'address'} ) . qq[ ] . $nt_obj->esc( $zr->{'ttl'} ) . qq[ ] . $nt_obj->esc( $zr->{'weight'} ) . qq[ ] . $nt_obj->esc( $zr->{'description'} ) . qq[
]; @@ -131,7 +147,9 @@ sub delegate_zones { #TODO handle 600 error where object is already delegated my $dzone = join( ', ', - map(qq[ zone $_->{'zone'} ], + map(qq[ zone ] + . $nt_obj->esc( $_->{'zone'} ) + . qq[ ], @$zones ) ); @@ -150,7 +168,7 @@ sub delegate_zones { $nt_obj->display_nice_error( $message, "Delegate Zone Records" ) if $message; - display_record( $title, $zone, $zr ); + display_record( $nt_obj, $title, $zone, $zr ); } if ( !$edit ) { @@ -182,7 +200,7 @@ sub delegate_zones { -method => 'POST', -name => $edit ), - "\n", + $nt_obj->csrf_hidden_field(), "\n", $q->hidden( -name => 'obj_list', -value => join( ',', $q->multi_param('obj_list') ), @@ -214,10 +232,12 @@ sub delegate_zones { GroupDelegated By - $del->{'group_name'} + ] + . $nt_obj->esc( $del->{'group_name'} ) . qq[ - $del->{'delegated_by_name'} + ] + . $nt_obj->esc( $del->{'delegated_by_name'} ) . qq[ diff --git a/client/htdocs/group.cgi b/client/htdocs/group.cgi index f6452b0f..9933cea4 100755 --- a/client/htdocs/group.cgi +++ b/client/htdocs/group.cgi @@ -34,7 +34,11 @@ sub main { return if !$user || !ref $user; - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } @@ -54,9 +58,11 @@ sub display { my $error; if ( $q->param('new') && $q->param('Create') ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); $error = _display_new_group( $nt_obj, $q, \@fields ); } elsif ( $q->param('edit') && $q->param('Save') ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); $error = _display_edit_group( $nt_obj, $q, \@fields ); } @@ -97,7 +103,7 @@ sub display { } } - if ( $q->param('delete') ) { + if ( $q->param('delete') && $nt_obj->verify_csrf() ) { my $rv = $nt_obj->delete_group( nt_group_id => scalar( $q->param('delete') ) ); $nt_obj->display_nice_error( $rv, "Delete Group" ) if $rv->{'error_code'} != 200; @@ -150,9 +156,8 @@ sub display_zone_search { print qq[ ]; } else { - print "\n "; + print "\n "; } } print "\n "; diff --git a/client/htdocs/group_nameservers.cgi b/client/htdocs/group_nameservers.cgi index 55455292..fddeb06f 100755 --- a/client/htdocs/group_nameservers.cgi +++ b/client/htdocs/group_nameservers.cgi @@ -33,7 +33,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -78,6 +82,8 @@ sub do_new { return; } + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + my @fields = qw/ nt_group_id name ttl description address address6 logdir datadir remote_login export_format export_interval export_serials /; my %data; @@ -93,7 +99,8 @@ sub do_new { sub do_delete { my ( $nt_obj, $q ) = @_; - return if !$q->param('delete'); + return if !$q->param('delete'); + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); my $error = $nt_obj->delete_nameserver( nt_group_id => scalar( $q->param('nt_group_id') ), @@ -115,6 +122,8 @@ sub do_edit { return; } + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + # user clicked the 'Save' button my @fields = qw/ nt_group_id nt_nameserver_id name ttl description address address6 logdir datadir remote_login @@ -196,7 +205,8 @@ sub display_list { $nt_obj->display_move_javascript( 'move_nameservers.cgi', 'nameserver' ); print qq[ -]; +] + . $nt_obj->csrf_hidden_field(); display_list_header( $nt_obj, $q, $rv, \@columns, \%labels, $user_group, \%sort_fields ); print qq[\n ]; @@ -214,10 +224,10 @@ sub display_list { display_list_name( $obj, $width ); foreach (qw/ description address status /) { - print qq[\n ]; + print qq[\n ]; } - display_list_delete( $q, $user, $obj, $state ); + display_list_delete( $nt_obj, $q, $user, $obj, $state ); print qq[ ]; @@ -345,8 +355,13 @@ sub display_list_subgroups { ); if ($map) { unshift @list, @{ $map->{ $obj->{'nt_group_id'} } }; } - my $url = qq[$_->{'name'}], @list ) ); + my $group_string = join( + ' / ', + map( qq[] + . NicToolClient::html_escape( undef, $_->{'name'} ) + . qq[], + @list ) + ); print qq[ $group_string ]; @@ -358,7 +373,8 @@ sub display_list_name { print qq[ ]; } @@ -405,16 +421,20 @@ sub display_list_options { } sub display_list_delete { - my ( $q, $user, $obj, $state ) = @_; + my ( $nt_obj, $q, $user, $obj, $state ) = @_; if ( $user->{'nameserver_delete'} && ( !exists $obj->{'delegate_delete'} || $obj->{'delegate_delete'} ) ) { - my $gid = $q->param('nt_group_id'); + my $gid = $q->param('nt_group_id'); + my $csrf = $nt_obj->get_csrf_token(); print qq[ ]; } else { @@ -427,7 +447,7 @@ sub display_list_delete { sub display_edit_nameserver { my ( $nt_obj, $user, $q, $message, $edit ) = @_; -# logdir + # logdir my @fields = qw/ name address address6 export_format datadir remote_login ttl export_interval export_serials description /; @@ -457,6 +477,7 @@ sub display_edit_nameserver { my $gid = $q->param('nt_group_id'); print qq[ + ] . $nt_obj->csrf_hidden_field() . qq[ ]; @@ -523,7 +544,7 @@ sub display_edit_nameserver_fields { -size => 45, -maxlength => 127 ) - : $nameserver->{'name'}, + : NicToolClient::html_escape( undef, $nameserver->{'name'} ), }, ttl => { label => 'TTL', @@ -534,7 +555,7 @@ sub display_edit_nameserver_fields { -maxlength => 10, -default => $ttl ) - : $nameserver->{'ttl'}, + : NicToolClient::html_escape( undef, $nameserver->{'ttl'} ), }, description => { label => 'Description', @@ -545,7 +566,7 @@ sub display_edit_nameserver_fields { -rows => 4, -maxlength => 255 ) - : $nameserver->{'description'}, + : NicToolClient::html_escape( undef, $nameserver->{'description'} ), }, address => { label => 'IPv4 Address', @@ -555,7 +576,7 @@ sub display_edit_nameserver_fields { -size => 20, -maxlength => 15 ) - : $nameserver->{'address'}, + : NicToolClient::html_escape( undef, $nameserver->{'address'} ), }, address6 => { label => 'IPv6 Address', @@ -565,7 +586,7 @@ sub display_edit_nameserver_fields { -size => 45, -maxlength => 39 ) - : $nameserver->{'address6'}, + : NicToolClient::html_escape( undef, $nameserver->{'address6'} ), }, remote_login => { label => 'Remote Login', @@ -609,7 +630,7 @@ sub display_edit_nameserver_fields { -size => 60, -maxlength => 255 ) - : $nameserver->{logdir}, + : NicToolClient::html_escape( undef, $nameserver->{logdir} ), }, datadir => { label => 'Data Directory', @@ -619,7 +640,7 @@ sub display_edit_nameserver_fields { -size => 45, -maxlength => 255 ) - : $nameserver->{datadir}, + : NicToolClient::html_escape( undef, $nameserver->{datadir} ), }, export_interval => { label => 'Export Interval (seconds)', @@ -630,7 +651,7 @@ sub display_edit_nameserver_fields { -maxlength => 10, -default => 120, ) - : $nameserver->{export_interval}, + : NicToolClient::html_escape( undef, $nameserver->{export_interval} ), }, ); } diff --git a/client/htdocs/group_users.cgi b/client/htdocs/group_users.cgi index 8569bc95..71835bab 100755 --- a/client/htdocs/group_users.cgi +++ b/client/htdocs/group_users.cgi @@ -33,7 +33,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -63,6 +67,7 @@ sub display { if ( $q->param('new') ) { if ( $q->param('Create') ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); display_save( $nt_obj, $q, $user, $group, \@fields, 'new' ); } elsif ( $q->param('Cancel') ) { } # do nothing @@ -72,6 +77,7 @@ sub display { } elsif ( $q->param('edit') ) { if ( $q->param('Save') ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); display_save( $nt_obj, $q, $user, $group, \@fields, 'edit' ); } elsif ( $q->param('Cancel') ) { } # do nothing @@ -80,7 +86,7 @@ sub display { } } - if ( $q->param('delete') ) { + if ( $q->param('delete') && $nt_obj->verify_csrf() ) { my $error = $nt_obj->delete_users( user_list => scalar( $q->param('obj_list') ) ); if ( $error->{'error_code'} != 200 ) { $nt_obj->display_nice_error( $error, "Delete Users" ); @@ -136,6 +142,7 @@ sub display_edit_user { -method => 'POST', -name => 'perms_form' ); + print $nt_obj->csrf_hidden_field(); print $q->hidden( -name => $edit ); print $q->hidden( -name => 'nt_group_id' ); if ( $edit eq 'edit' ) { @@ -299,7 +306,8 @@ sub display_list { -method => 'POST', -name => 'list_form', -target => 'move_win' - ); + ), + $nt_obj->csrf_hidden_field(); } print qq[ @@ -386,9 +394,13 @@ sub display_list { ); if ($map) { unshift @list, @{ $map->{ $obj->{'nt_group_id'} } }; } - my $url = qq[$_->{'name'}], @list ) ); + my $group_string = join( + ' / ', + map( qq[] + . NicToolClient::html_escape( undef, $_->{'name'} ) + . qq[], + @list ) + ); print qq[ $group_string @@ -403,20 +415,26 @@ sub display_list {
- ], - $q->start_form( -action => 'group.cgi', -method => 'POST' ), - $q->hidden( -name => 'nt_group_id' ), + ], $q->start_form( -action => 'group.cgi', -method => 'POST' ), + $nt_obj->csrf_hidden_field(), $q->hidden( -name => 'nt_group_id' ), qq[ ]; @@ -221,13 +229,13 @@ sub display_log {
], $q->textfield( -name => 'search_value', -size => 30, -override => 1 ), $q->hidden( -name => 'quick_search', @@ -213,7 +218,7 @@ sub display_group_list { if $rv->{'error_code'} != 200; my $groups = $rv->{'groups'}; - my $map = $rv->{'group_map'}; + my $map = ref $rv->{'group_map'} eq 'HASH' ? $rv->{'group_map'} : {}; $nt_obj->display_search_rows( $q, $rv, \%params, $cgi, ['nt_group_id'], $include_subgroups ); @@ -239,8 +244,10 @@ sub display_group_list { my $gname = $group->{'name'} . "'s"; my $dname = join( ' / ', - map( qq[$_->{'name'}], - ( @{ $map->{$ggid} }, + map( qq[] + . $nt_obj->esc( $_->{'name'} ) + . qq[], + ( @{ $map->{$ggid} || [] }, { nt_group_id => $ggid, name => $group->{'name'} } @@ -259,15 +266,16 @@ sub display_group_list { my $hname = join( ' / ', map( $_->{'name'}, - ( @{ $map->{ $group->{'nt_group_id'} } }, + ( @{ $map->{ $group->{'nt_group_id'} } || [] }, { nt_group_id => $group->{'nt_group_id'}, name => $group->{'name'} } ) ) ); + my $js_hname = $nt_obj->esc( $nt_obj->js_escape($hname) ); print qq[
  • - trash
  • ]; + trash]; } else { print qq[ @@ -346,6 +354,7 @@ sub display_edit { -method => 'POST', -name => 'perms_form' ), + $nt_obj->csrf_hidden_field(), $q->hidden( -name => $edit ); if ( $edit eq 'new' ) { $q->hidden( -name => 'parent_group_id' ); @@ -358,7 +367,7 @@ sub display_edit { } my $action = 'View'; - my $name = qq[$data->{'name'}]; + my $name = qq[] . $nt_obj->esc( $data->{'name'} ) . qq[]; if ($modifyperm) { $action = ucfirst($edit); @@ -407,7 +416,9 @@ sub display_edit { foreach ( keys %nsmap ) { my $ns = $nt_obj->get_nameserver( nt_nameserver_id => $_ ); - print "
  • $ns->{'description'} ($ns->{'name'})
    "; + print "
  • " + . $nt_obj->esc( $ns->{'description'} ) . " (" + . $nt_obj->esc( $ns->{'name'} ) . ")
    "; } print qq[ diff --git a/client/htdocs/group_log.cgi b/client/htdocs/group_log.cgi index 22b6e2d1..710dcba5 100755 --- a/client/htdocs/group_log.cgi +++ b/client/htdocs/group_log.cgi @@ -37,7 +37,11 @@ sub main { if ( $q->param('redirect') ) { $message = $nt_obj->redirect_from_log($q); } - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user, $message ); } } @@ -185,8 +189,10 @@ sub display_log {
  • ], join( ' / ', - map( qq[$_->{'name'}], - ( @{ $map->{ $row->{'nt_group_id'} } }, + map( qq[] + . $nt_obj->esc( $_->{'name'} ) + . qq[], + ( @{ $map->{ $row->{'nt_group_id'} } || [] }, { nt_group_id => $row->{'nt_group_id'}, name => $row->{'group_name'} } @@ -203,7 +209,9 @@ sub display_log { - +
    $row->{'user'}] + . $nt_obj->esc( $row->{'user'} ) + . qq[
    - +
    $row->{'title'}] . $nt_obj->esc( $row->{'title'} ) . qq[
    $row->{$_}" . $nt_obj->esc( $row->{$_} ) . "
    $obj->{$_} ] . $nt_obj->esc( $obj->{$_} ) . qq[
    - nameserver $obj->{'name'} + nameserver ] + . NicToolClient::html_escape( undef, $obj->{'name'} ) . qq[ - + {'name'}?" ) ) + . qq[');"> trash
    - +
    user$obj->{'username'}] . NicToolClient::html_escape( undef, $obj->{'username'} ) . qq[
    - $obj->{'first_name'} - $obj->{'last_name'} - $obj->{'email'}]; + ] . NicToolClient::html_escape( undef, $obj->{'first_name'} ) . qq[ + ] . NicToolClient::html_escape( undef, $obj->{'last_name'} ) . qq[ + ] + . NicToolClient::html_escape( undef, $obj->{'email'} ) . qq[]; if ( $user->{'user_delete'} && $obj->{'nt_user_id'} != $user->{'nt_user_id'} ) { print qq[ - trash]; + {'username'}?" ) ) + . qq[');">trash]; } else { print qq[ diff --git a/client/htdocs/group_zones.cgi b/client/htdocs/group_zones.cgi index 2cb84c5a..3145a987 100755 --- a/client/htdocs/group_zones.cgi +++ b/client/htdocs/group_zones.cgi @@ -42,7 +42,11 @@ sub display { my ( $newzone, $nicemessage ) = _display_new( $nt_obj, $q, $user ); - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); $nt_obj->parse_template($NicToolClient::start_html_template); $nt_obj->parse_template( $NicToolClient::body_frame_start_template, @@ -86,8 +90,9 @@ sub display { sub _display_delete { my ( $nt_obj, $q ) = @_; - return if !$q->param('delete'); - return if !$q->param('zone_list'); + return if !$q->param('delete'); + return if !$q->param('zone_list'); + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); my @zl = $q->multi_param('zone_list'); my $error = $nt_obj->delete_zones( zone_list => join( ',', @zl ) ); @@ -107,9 +112,10 @@ sub _display_delete { sub _display_delete_delegate { my ( $nt_obj, $q ) = @_; - return if !$q->param('deletedelegate'); - return if !$q->param('nt_zone_id'); - return if !$q->param('nt_group_id'); + return if !$q->param('deletedelegate'); + return if !$q->param('nt_zone_id'); + return if !$q->param('nt_group_id'); + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); my $error = $nt_obj->delete_zone_delegation( nt_zone_id => scalar( $q->param('nt_zone_id') ), @@ -131,6 +137,8 @@ sub _display_edit { return if $q->param('Cancel'); # do nothing if ( $q->param('Save') ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + my @fields = qw/ nt_zone_id nt_group_id zone nameservers description serial refresh retry expire minimum mailaddr ttl /; @@ -158,6 +166,8 @@ sub _display_new { return [ $nt_obj, $q, '', 'new' ]; } + return if !$nt_obj->verify_csrf(); + my $r = add_zone( $nt_obj, $q, $user ) or return; return ( $r->{newzone}, $r->{nicemessage} ); } @@ -225,7 +235,8 @@ sub display_list { $nt_obj->display_delegate_javascript( 'delegate_zones.cgi', 'zone' ); print qq[ - +] + . $nt_obj->csrf_hidden_field() . qq[ ]; display_list_header( \@columns, \%labels, \%sort_fields, $user_group, $rv ); @@ -247,9 +258,10 @@ sub display_list { display_list_zone_name( $zone, $width, $bgcolor, $gid ); display_list_group_name( $zone, $width, $map ) if $include_subgroups; print qq[ - ]; + ]; display_list_delegate_icon( $zone, $user, $user_group ); - display_list_delete_icon( $zone, $user, $gid, $state_string ); + display_list_delete_icon( $nt_obj, $zone, $user, $gid, $state_string ); print qq[ ]; } @@ -323,16 +335,17 @@ sub display_list_zone_name { print qq[ ]; + qq[trash]; } elsif ( $isdelegate && ( $user->{'zone_delegate'} && $zone->{'delegate_delete'} ) ) { + my $js_zone = + NicToolClient::html_escape( undef, NicToolClient::js_escape( undef, $zone->{'zone'} ) ); print - qq[Remove Zone Delegation]; + qq[Remove Zone Delegation]; } elsif ($isdelegate) { print @@ -424,6 +447,7 @@ sub display_new_zone { -method => 'POST', -name => 'new_zone' ), + $nt_obj->csrf_hidden_field(), $q->hidden( -name => 'nt_group_id' ), $q->hidden( -name => $edit ); diff --git a/client/htdocs/group_zones_log.cgi b/client/htdocs/group_zones_log.cgi index 8eb32741..af7f2c33 100755 --- a/client/htdocs/group_zones_log.cgi +++ b/client/htdocs/group_zones_log.cgi @@ -38,7 +38,11 @@ sub main { $message = $nt_obj->redirect_from_log($q); } - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user, $message ); } } @@ -178,7 +182,9 @@ sub display_log {
    $zone->{'description'}] + . $nt_obj->esc( $zone->{'description'} ) . qq[
    ]; + my $esc_zone = NicToolClient::html_escape( undef, $zone->{'zone'} ); if ( !$isdelegate ) { print qq[ - zone$zone->{'zone'}]; + zone$esc_zone]; } else { my $img = "zone" . ( $zone->{'pseudo'} ? '-pseudo' : '-delegated' ); print qq[ - $zone->{'zone'}]; + $esc_zone]; if ( $zone->{'pseudo'} ) { print qq[  ($zone->{'delegated_records'} record]; print 's' if $zone->{'delegated_records'} gt 1; @@ -363,8 +376,13 @@ sub display_list_group_name { ); if ( $map && $map->{$gid} ) { unshift @list, @{ $map->{$gid} }; } - my $url = qq[$_->{'name'}], @list ) ); + my $group_string = join( + ' / ', + map( qq[] + . NicToolClient::html_escape( undef, $_->{'name'} ) + . qq[], + @list ) + ); print qq[ $group_string
    @@ -392,19 +410,24 @@ sub display_list_delegate_icon { } sub display_list_delete_icon { - my ( $zone, $user, $gid, $state_string ) = @_; + my ( $nt_obj, $zone, $user, $gid, $state_string ) = @_; my $isdelegate = exists $zone->{'delegated_by_id'}; + my $csrf = $nt_obj->get_csrf_token(); print qq[
    ]; if ( $user->{'zone_delete'} && !$isdelegate ) { + my $js_zone = + NicToolClient::html_escape( undef, NicToolClient::js_escape( undef, $zone->{'zone'} ) ); print - qq[trash
    - +
    $row->{$_}] + . NicToolClient::html_escape( undef, $row->{$_} ) + . qq[
    ]; } @@ -189,7 +195,9 @@ sub display_log { my $url = "user.cgi?nt_group_id=$gid&nt_user_id=$row->{'nt_user_id'}"; print qq[ -
    user$row->{'user'}
    ]; +] + . NicToolClient::html_escape( undef, $row->{'user'} ) + . qq[ ]; } elsif ( $_ eq 'group' ) { print qq[ @@ -197,8 +205,10 @@ sub display_log {
    ], join( ' / ', - map( qq[$_->{'name'}], - ( @{ $map->{ $row->{'nt_group_id'} } }, + map( qq[] + . NicToolClient::html_escape( undef, $_->{'name'} ) + . qq[], + ( @{ $map->{ $row->{'nt_group_id'} } || [] }, { nt_group_id => $row->{'nt_group_id'}, name => $row->{'group_name'} } @@ -207,7 +217,9 @@ sub display_log { "
    "; } else { - print "", ( $row->{$_} ? $row->{$_} : ' ' ), ""; + print "", + ( $row->{$_} ? NicToolClient::html_escape( undef, $row->{$_} ) : ' ' ), + ""; } } if ( $row->{'action'} eq 'deleted' ) { diff --git a/client/htdocs/group_zones_query_log.cgi b/client/htdocs/group_zones_query_log.cgi index a3c613b3..3ad2df56 100755 --- a/client/htdocs/group_zones_query_log.cgi +++ b/client/htdocs/group_zones_query_log.cgi @@ -33,7 +33,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -139,23 +143,27 @@ sub display { } elsif ( $_ eq 'nameserver' ) { print qq[ - +
    $row->{$_}] . NicToolClient::html_escape( undef, $row->{$_} ) . qq[
    ]; } elsif ( $_ eq 'zone' ) { print qq[ - +
    $row->{$_}] + . NicToolClient::html_escape( undef, $row->{$_} ) + . qq[
    ]; } elsif ( $_ eq 'query' ) { print qq[ - +
    $row->{$_}] + . NicToolClient::html_escape( undef, $row->{$_} ) + . qq[
    ]; } else { - print "", $row->{$_}, ""; + print "", NicToolClient::html_escape( undef, $row->{$_} ), ""; } } print ""; diff --git a/client/htdocs/help.cgi b/client/htdocs/help.cgi index 280bccdf..1ed9dbf9 100755 --- a/client/htdocs/help.cgi +++ b/client/htdocs/help.cgi @@ -33,7 +33,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } diff --git a/client/htdocs/index.cgi b/client/htdocs/index.cgi index 16c07ce3..78000f02 100755 --- a/client/htdocs/index.cgi +++ b/client/htdocs/index.cgi @@ -42,7 +42,9 @@ sub main { my $cookie = $q->cookie('NicTool'); if ( !$cookie ) { - $nt_obj->display_login( scalar( $q->param('message') ) ); + my $msg = scalar( $q->param('message') ) || ''; + $msg =~ s/[^a-zA-Z0-9 .,!?:;\-]//g; # allow only safe characters + $nt_obj->display_login($msg); return; } @@ -71,8 +73,6 @@ sub display_frameset { $nt_obj->set_cookie( $data->{nt_user_session} ); - print $nt_obj->{'CGI'}->header( -charset => "utf-8" ); - $nt_obj->parse_template( $NicToolClient::frameset_template, nt_group_id => $data->{'nt_group_id'} ); } @@ -80,6 +80,11 @@ sub display_frameset { sub do_login { my ( $nt_obj, $q ) = @_; + if ( !$nt_obj->verify_csrf() ) { + $nt_obj->display_login('Session expired. Please try again.'); + return; + } + # login form was submitted, make sure user/pass was too if ( $q->param('username') eq '' or $q->param('password') eq '' ) { $nt_obj->display_login('Please enter your username and password!'); diff --git a/client/htdocs/move_nameservers.cgi b/client/htdocs/move_nameservers.cgi index 2cbb92f2..8d2a23bd 100755 --- a/client/htdocs/move_nameservers.cgi +++ b/client/htdocs/move_nameservers.cgi @@ -33,7 +33,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -54,7 +58,7 @@ sub display { # do nothing } - elsif ( $q->param('Save') ) { + elsif ( $q->param('Save') && $nt_obj->verify_csrf() ) { my $rv = $nt_obj->move_nameservers( nt_group_id => scalar( $q->param('group_list') ), nameserver_list => scalar( $q->param('obj_list') ) diff --git a/client/htdocs/move_users.cgi b/client/htdocs/move_users.cgi index e3bab4bb..669abd1c 100755 --- a/client/htdocs/move_users.cgi +++ b/client/htdocs/move_users.cgi @@ -33,7 +33,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -54,7 +58,7 @@ sub display { # do nothing } - elsif ( $q->param('Save') ) { + elsif ( $q->param('Save') && $nt_obj->verify_csrf() ) { my $rv = $nt_obj->move_users( nt_group_id => scalar( $q->param('group_list') ), user_list => scalar( $q->param('obj_list') ) diff --git a/client/htdocs/move_zones.cgi b/client/htdocs/move_zones.cgi index 604913e0..904d6385 100755 --- a/client/htdocs/move_zones.cgi +++ b/client/htdocs/move_zones.cgi @@ -33,7 +33,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -54,7 +58,7 @@ sub display { # do nothing } - elsif ( $q->param('Save') ) { + elsif ( $q->param('Save') && $nt_obj->verify_csrf() ) { my $rv = $nt_obj->move_zones( nt_group_id => scalar( $q->param('group_list') ), zone_list => scalar( $q->param('obj_list') ) diff --git a/client/htdocs/nav.cgi b/client/htdocs/nav.cgi index e9c1f78c..748639c9 100755 --- a/client/htdocs/nav.cgi +++ b/client/htdocs/nav.cgi @@ -32,7 +32,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } diff --git a/client/htdocs/templates.cgi b/client/htdocs/templates.cgi index 23084a88..8d2e4399 100755 --- a/client/htdocs/templates.cgi +++ b/client/htdocs/templates.cgi @@ -18,7 +18,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } diff --git a/client/htdocs/user.cgi b/client/htdocs/user.cgi index b558b2c8..0bd5f337 100755 --- a/client/htdocs/user.cgi +++ b/client/htdocs/user.cgi @@ -37,7 +37,11 @@ sub main { if ( $q->param('redirect') ) { $message = $nt_obj->redirect_from_log($q); } - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user, $message ); } } @@ -61,11 +65,13 @@ sub display { #warn "user info: ".Data::Dumper::Dumper($user); my $edit_message; -# send the request to NicToolServer and parse result + # send the request to NicToolServer and parse result if ( ( $q->param('edit') && $q->param('Save') ) || ( $q->param('new') && $q->param('Create') ) ) { + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + my ( $error, %data ); my @fields = qw/ user_create user_delete user_write group_create group_delete group_write zone_create zone_delegate zone_delete zone_write zonerecord_create zonerecord_delegate zonerecord_delete zonerecord_write nameserver_create nameserver_delete nameserver_write self_write /; @@ -130,7 +136,9 @@ sub display { push @options, qq[Delete]; + . qq[&delete=1&obj_list=$duser->{'nt_user_id'}" onClick="return confirm('] + . $nt_obj->esc( $nt_obj->js_escape("Delete user $duser->{'username'}?") ) + . qq[');">Delete]; } else { push @options, "Delete"; @@ -161,7 +169,7 @@ sub display { } print qq[ -$duser->{'username'} +] . $nt_obj->esc( $duser->{'username'} ) . qq[ ], join( ' | ', @options ), qq[ ]; @@ -234,20 +242,28 @@ sub display_properties { - + - +
    Username: $duser->{'username'}Username: ] + . $nt_obj->esc( $duser->{'username'} ) + . qq[
    Email: $duser->{'email'}Email: ] + . $nt_obj->esc( $duser->{'email'} ) + . qq[
    - + - +
    First Name: $duser->{'first_name'}First Name: ] + . $nt_obj->esc( $duser->{'first_name'} ) + . qq[
    Last Name: $duser->{'last_name'}Last Name: ] + . $nt_obj->esc( $duser->{'last_name'} ) + . qq[
    @@ -369,7 +385,7 @@ sub display_global_log { - +
    image$row->{'title'}] . $nt_obj->esc( $row->{'title'} ) . qq[
    ]; } elsif ( $_ eq 'target' && $row->{'target_id'} ) { @@ -383,13 +399,13 @@ sub display_global_log { - +
    $row->{'target_name'}] . $nt_obj->esc( $row->{'target_name'} ) . qq[
    ]; } else { - print "", ( $row->{$_} ? $row->{$_} : ' ' ), ""; + print "", ( $row->{$_} ? $nt_obj->esc( $row->{$_} ) : ' ' ), ""; } } print ""; @@ -421,6 +437,7 @@ sub display_edit { -method => 'POST', -name => 'perms_form' ); + print $nt_obj->csrf_hidden_field(); print $q->hidden( -name => 'nt_group_id' ); print $q->hidden( -name => 'nt_user_id' ) if $edit eq 'edit'; print $q->hidden( -name => $edit ); @@ -447,7 +464,7 @@ sub display_edit { -value => $duser->{'username'}, -size => 30 ) - : $duser->{'username'} + : $nt_obj->esc( $duser->{'username'} ) ), qq[ @@ -461,7 +478,7 @@ sub display_edit { -value => $duser->{'first_name'}, -size => 30 ) - : $duser->{'first_name'} + : $nt_obj->esc( $duser->{'first_name'} ) ), qq[ @@ -475,7 +492,7 @@ sub display_edit { -value => $duser->{'last_name'}, -size => 40 ) - : $duser->{'last_name'} + : $nt_obj->esc( $duser->{'last_name'} ) ), qq[ @@ -489,7 +506,7 @@ sub display_edit { -value => $duser->{'email'}, -size => 60 ) - : $duser->{'email'} + : $nt_obj->esc( $duser->{'email'} ) ), " "; diff --git a/client/htdocs/zone.cgi b/client/htdocs/zone.cgi index 8277ec47..f768652e 100755 --- a/client/htdocs/zone.cgi +++ b/client/htdocs/zone.cgi @@ -24,6 +24,8 @@ require 'nictoolclient.conf'; main(); +sub _esc { NicToolClient::html_escape( undef, $_[0] ) } + sub main { my $q = new CGI(); my $nt_obj = new NicToolClient($q); @@ -33,7 +35,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -99,8 +105,9 @@ sub display_zone { sub do_delete_delegation { my ( $nt_obj, $q ) = @_; - return if !$q->param('deletedelegate'); - return if !$q->param('delegate_group_id'); + return if !$q->param('deletedelegate'); + return if !$q->param('delegate_group_id'); + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); if ( $q->param('type') ne 'record' && $q->param('nt_zone_id') ) { my $error = $nt_obj->delete_zone_delegation( @@ -142,6 +149,8 @@ sub do_edit_zone { return; } + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + my @fields = qw/ nt_zone_id nt_group_id zone description mailaddr serial refresh retry expire ttl minimum /; my %data; @@ -176,6 +185,8 @@ sub do_new_zone { return; } + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + my @fields = qw/ nt_group_id zone nameservers description mailaddr serial refresh retry expire ttl minimum/; my %data = map { $_ => scalar( $q->param($_) ) } @fields; @@ -228,7 +239,8 @@ sub display_zone_delegate { Delegated by: - user $zone->{'delegated_by_name'} + user ] + . _esc( $zone->{'delegated_by_name'} ) . qq[ ]; } @@ -247,7 +259,7 @@ sub display_zone_delegate { - +
    group $zone->{'group_name'} ] . _esc( $zone->{'group_name'} ) . qq[
    @@ -313,8 +325,10 @@ sub display_zone_delegation { foreach my $del ( @{ $delegates->{'delegates'} } ) { print qq[ - group$del->{'group_name'} - user $del->{'delegated_by_name'} + group] + . _esc( $del->{'group_name'} ) . qq[ + user ] + . _esc( $del->{'delegated_by_name'} ) . qq[ {'nt_zone_id'}&nt_group_id=$gid&delegate_group_id=$del->{'nt_group_id'}&deletedelegate=1" onClick="return confirm('Are you sure you want to remove the delegation of zone $zone->{'zone'} to group $del->{'group_name'}?');">Remove Delegation]; + qq[{'zone'} to group $del->{'group_name'}?" + ) + ) + . qq[');">Remove Delegation]; } else { print @@ -396,7 +418,7 @@ sub display_zone_properties { print qq[ $_: - $zone->{$_} + ] . _esc( $zone->{$_} ) . qq[ ],; } print qq[ @@ -408,7 +430,7 @@ sub display_zone_properties { print qq[ $_: - $zone->{$_} + ] . _esc( $zone->{$_} ) . qq[ ]; } @@ -466,9 +488,10 @@ sub display_nameservers { my $bgcolor = $x++ % 2 == 0 ? 'light_grey_bg' : 'white_bg'; print qq[ - nameserver$ns->{name} - $ns->{address} - $ns->{description} + nameserver] + . _esc( $ns->{name} ) . qq[ + ] . _esc( $ns->{address} ) . qq[ + ] . _esc( $ns->{description} ) . qq[ ]; } @@ -521,7 +544,7 @@ sub display_zone_records { } my $state_string = join( '&', @state_fields ); -# Display the RR header: Resource Records New Resource Record | View Resource Record Log + # Display the RR header: Resource Records New Resource Record | View Resource Record Log my $gid = $q->param('nt_group_id'); my $zonedelegate = exists $zone->{'delegated_by_id'}; @@ -551,39 +574,53 @@ sub display_zone_records { $r_record->{name} = "@ ($zone->{'zone'})" if $r_record->{name} eq "@"; + # save raw values for confirm dialog before any display mutations + my $raw_name = $r_record->{name}; + my $raw_address = $r_record->{address}; + # shorten the max width of the address field (workaround for # display formatting problem with DomainKey entries. if ( length $r_record->{address} > 45 ) { if ( $r_record->{type} =~ /^(?:DNSKEY|RRSIG)$/ ) { - $r_record->{title} = $r_record->{address}; + $r_record->{title} = _esc( $r_record->{address} ); $r_record->{address} = - substr( $r_record->{address}, 0, 35 ) . ' ...
    (tip: hover over address)'; + _esc( substr( $r_record->{address}, 0, 35 ) ) + . ' ...
    (tip: hover over address)'; } elsif ( $r_record->{type} =~ /^(?:TXT)$/ && length $r_record->{address} > 100 ) { - $r_record->{title} = $r_record->{address}; + $r_record->{title} = _esc( $r_record->{address} ); $r_record->{address} = - substr( $r_record->{address}, 0, 35 ) . ' ...
    (tip: hover over address)'; + _esc( substr( $r_record->{address}, 0, 35 ) ) + . ' ...
    (tip: hover over address)'; } else { my $max = 0; my @lines = (); while ( $max < length $r_record->{address} ) { - push @lines, substr( $r_record->{address}, $max, 40 ); + push @lines, _esc( substr( $r_record->{address}, $max, 40 ) ); $max += 40; } $r_record->{address} = join "
    ", @lines; } } + else { + $r_record->{address} = _esc( $r_record->{address} ); + } if ( $r_record->{type} eq 'IPSECKEY' ) { - $r_record->{description} = substr( $r_record->{description}, 0, 10 ) . ' ...'; + $r_record->{description} = _esc( substr( $r_record->{description}, 0, 10 ) ) . ' ...'; + } + else { + $r_record->{description} = _esc( $r_record->{description} ); } if ( $r_record->{type} eq 'AAAA' ) { $r_record->{address} =~ s/:[0]+/:/g; # compress leading zeros } + $r_record->{name} = _esc( $r_record->{name} ); + foreach (@columns) { if ( $_ eq 'name' ) { print qq[ @@ -610,8 +647,10 @@ sub display_zone_records { ]; } elsif ( $_ =~ /address|ttl|weight|priority|other/i ) { + my $title_attr = $r_record->{title} ? $r_record->{title} : ''; + my $val = $_ eq 'address' ? $r_record->{$_} : _esc( $r_record->{$_} ); print qq[ - $r_record->{$_} ]; + $val ]; } else { print qq[ @@ -643,13 +682,22 @@ sub display_zone_records { && !$isdelegate && ( $zonedelegate ? $zone->{'delegate_delete_records'} : 1 ) ) { - my $quoted_address = $r_record->{'address'}; - $quoted_address =~ s/["']//g; # remove " or ' from TXT/SPF records - $quoted_address =~ s/
    //g; # remove
    inserted 64 lines above + my $confirm_msg = $nt_obj->esc( + $nt_obj->js_escape( + "Are you sure you want to delete $zone->{'zone'} $r_record->{'type'} record $raw_name that points to $raw_address ?" + ) + ); print qq[ - {'zone'} $r_record->{'type'} record $r_record->{'name'} that points to $quoted_address ?')"> - trash]; + + ] . $nt_obj->csrf_hidden_field() . qq[ + + + + + + ]; } else { @@ -672,6 +720,8 @@ sub display_zone_records_new { return display_edit_record( $nt_obj, $user, $q, '', $zone, 'new' ) if !$q->param('Create'); + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + my @fields = qw/ nt_group_id nt_zone_id name type address weight priority other ttl location description /; @@ -699,6 +749,8 @@ sub display_zone_records_edit { return display_edit_record( $nt_obj, $user, $q, '', $zone, 'edit' ) if !$q->param('Save'); + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); + my @fields = qw( nt_group_id nt_zone_id nt_zone_record_id name type address weight priority other ttl description deleted location timestamp); my %data; @@ -716,7 +768,8 @@ sub display_zone_records_edit { sub display_zone_records_delete { my ( $nt_obj, $q ) = @_; - return if !$q->param('delete_record'); + return if !$q->param('delete_record'); + return $nt_obj->csrf_error_page() if !$nt_obj->verify_csrf(); my $error = $nt_obj->delete_zone_record( nt_group_id => scalar( $q->param('nt_group_id') ), @@ -834,6 +887,7 @@ sub display_edit_record { if ($modifyperm) { print qq[
    ], + $nt_obj->csrf_hidden_field(), $q->hidden( -name => $edit . '_record' ), "\n", $q->hidden( -name => 'nt_group_id' ), "\n", $q->hidden( -name => 'nt_zone_id' ), "\n"; @@ -998,10 +1052,10 @@ sub _build_rr_name { my ( $q, $zone_record, $zone, $modifyperm ) = @_; my $suffix = ''; - $suffix = ".$zone->{'zone'}." + $suffix = "." . _esc( $zone->{'zone'} ) . "." if $zone_record->{'name'} ne "$zone->{'zone'}."; - return $zone_record->{'name'} . $suffix if !$modifyperm; + return _esc( $zone_record->{'name'} ) . $suffix if !$modifyperm; return $q->textfield( -id => 'name', @@ -1011,7 +1065,7 @@ sub _build_rr_name { -default => $zone_record->{'name'}, -required => 'required', -# -pattern => 'TODO: apply label rules here', + # -pattern => 'TODO: apply label rules here', ) . $suffix; } @@ -1107,7 +1161,7 @@ sub _build_rr_type { sub _build_rr_address { my ( $q, $zone_record, $modifyperm ) = @_; - return $zone_record->{'address'} if !$modifyperm; + return _esc( $zone_record->{'address'} ) if !$modifyperm; return $q->textfield( -id => 'address', @@ -1122,7 +1176,7 @@ sub _build_rr_address { sub _build_rr_ttl { my ( $q, $zone_record, $modifyperm ) = @_; - return $zone_record->{'ttl'} if !$modifyperm; + return _esc( $zone_record->{'ttl'} ) if !$modifyperm; return $q->textfield( -id => 'ttl', @@ -1151,7 +1205,7 @@ if ( $('select#ttl').val() == '' && $('input#ttl').val() != '' ) sub _build_rr_weight { my ( $q, $zone_record, $modifyperm ) = @_; - return $zone_record->{'weight'} if !$modifyperm; + return _esc( $zone_record->{'weight'} ) if !$modifyperm; return $q->textfield( -id => 'weight', -name => 'weight', @@ -1167,7 +1221,7 @@ sub _build_rr_weight { sub _build_rr_priority { my ( $q, $zone_record, $modifyperm ) = @_; - return $zone_record->{'priority'} if !$modifyperm; + return _esc( $zone_record->{'priority'} ) if !$modifyperm; return $q->textfield( -id => 'priority', -name => 'priority', @@ -1183,7 +1237,7 @@ sub _build_rr_priority { sub _build_rr_other { my ( $q, $zone_record, $modifyperm ) = @_; - return $zone_record->{'other'} if !$modifyperm; + return _esc( $zone_record->{'other'} ) if !$modifyperm; return $q->textfield( -id => 'other', -name => 'other', @@ -1199,7 +1253,7 @@ sub _build_rr_other { sub _build_rr_description { my ( $q, $zone_record, $modifyperm ) = @_; - return $zone_record->{'description'} || ' ' if !$modifyperm; + return _esc( $zone_record->{'description'} ) || ' ' if !$modifyperm; return $q->textfield( -id => 'description', -name => 'description', @@ -1212,7 +1266,7 @@ sub _build_rr_description { sub _build_rr_timestamp { my ( $q, $zone_record, $modifyperm ) = @_; - return $zone_record->{'timestamp'} if !$modifyperm; + return _esc( $zone_record->{'timestamp'} ) if !$modifyperm; return $q->textfield( -id => 'timestamp', -name => 'timestamp', @@ -1225,7 +1279,7 @@ sub _build_rr_timestamp { sub _build_rr_location { my ( $q, $zone_record, $modifyperm ) = @_; - return $zone_record->{'location'} if !$modifyperm; + return _esc( $zone_record->{'location'} ) if !$modifyperm; return $q->textfield( -id => 'location', -name => 'location', @@ -1260,7 +1314,8 @@ sub display_edit_record_delegates { - +
    group$del->{'group_name'}] + . _esc( $del->{'group_name'} ) . qq[
    @@ -1269,7 +1324,8 @@ sub display_edit_record_delegates { - $del->{'delegated_by_name'} + ] + . _esc( $del->{'delegated_by_name'} ) . qq[ @@ -1302,7 +1358,15 @@ sub display_edit_record_delegates { if ( $user->{zonerecord_delegate} ) { my $gid = $q->param('nt_group_id'); print - qq[Remove Delegation]; + qq[{'name'} to group $del->{'group_name'}?" + ) + ) + . qq[');">Remove Delegation]; } else { print @@ -1337,7 +1401,7 @@ sub display_new_record_delegates { - +
    user $zone_record->{'delegated_by_name'} ] . _esc( $zone_record->{'delegated_by_name'} ) . qq[
    @@ -1348,7 +1412,7 @@ sub display_new_record_delegates { - +
    group $zone_record->{'group_name'} ] . _esc( $zone_record->{'group_name'} ) . qq[
    @@ -1389,7 +1453,13 @@ sub display_new_record_delegates { && $zone_record->{'delegate_delete'} ) { print - qq[Remove Delegation]; + qq[{'name'} to group $zone_record->{'group_name'}?" + ) + ) . qq[');">Remove Delegation]; } else { print "Remove Delegation"; @@ -1418,7 +1488,7 @@ sub display_edit_zone { my $action = 'Edit'; print qq[ -]; +] . $nt_obj->csrf_hidden_field(); my @hiddens = 'nt_group_id'; push @hiddens, 'nt_zone_id' if $edit eq 'edit'; @@ -1443,7 +1513,8 @@ sub display_edit_zone { print qq[ - +
    $action Zone: $zone->{zone}
    $action Zone: ] + . _esc( $zone->{zone} ) . qq[
    Nameservers: \n]; diff --git a/client/htdocs/zone_record_log.cgi b/client/htdocs/zone_record_log.cgi index 9cd7be7b..628d7694 100755 --- a/client/htdocs/zone_record_log.cgi +++ b/client/htdocs/zone_record_log.cgi @@ -38,7 +38,11 @@ sub main { $message = $nt_obj->redirect_from_log($q); } - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user, $message ); } } @@ -187,10 +191,12 @@ sub display_log { if ( !$zone->{'deleted'} ) { print qq[ $row->{$_} ]; + qq[&redirect=1&object=zone_record&obj_id=$row->{'nt_zone_record_id'}&nt_zone_id=$row->{'nt_zone_id'}&nt_group_id=$gid"> ] + . NicToolClient::html_escape( undef, $row->{$_} ) + . qq[ ]; } else { - print $row->{$_}; + print NicToolClient::html_escape( undef, $row->{$_} ); } print "
    "; } @@ -200,11 +206,15 @@ sub display_log { elsif ( $_ eq 'user' ) { print qq[ - +
    $row->{'user'}] + . NicToolClient::html_escape( undef, $row->{'user'} ) + . qq[
    ]; } else { - print "", ( $row->{$_} ? $row->{$_} : ' ' ), ""; + print "", + ( $row->{$_} ? NicToolClient::html_escape( undef, $row->{$_} ) : ' ' ), + ""; } } if ( !$zone->{'deleted'} ) { diff --git a/client/htdocs/zones.cgi b/client/htdocs/zones.cgi index 40a6c4ef..a9d06c72 100755 --- a/client/htdocs/zones.cgi +++ b/client/htdocs/zones.cgi @@ -18,7 +18,11 @@ sub main { my $user = $nt_obj->verify_session(); if ( $user && ref $user ) { - print $q->header( -charset => "utf-8" ); + print $q->header( + -charset => "utf-8", + -cookie => $nt_obj->csrf_cookie( $nt_obj->get_csrf_token() ), + %{ $nt_obj->security_headers() } + ); display( $nt_obj, $q, $user ); } } @@ -47,7 +51,7 @@ sub display { print_zone_request_form( $nt_obj, $q, %vars ); - if ( $q->param('action') ) { + if ( $q->param('action') && $nt_obj->verify_csrf() ) { # Process form inputs print "processing form inputs
    \n" if $vars{'debug'}; @@ -82,7 +86,9 @@ sub print_zone_request_form { my @actions = ("add"); my $gid = $q->param('nt_group_id'); - print $q->start_form, $q->hidden( -name => 'nt_group_id', -default => $gid ); + print $q->start_form, + $nt_obj->csrf_hidden_field(), + $q->hidden( -name => 'nt_group_id', -default => $gid ); print qq{ diff --git a/client/lib/NicToolClient.pm b/client/lib/NicToolClient.pm index bfd06f29..25c2cc9d 100644 --- a/client/lib/NicToolClient.pm +++ b/client/lib/NicToolClient.pm @@ -8,8 +8,8 @@ use NicToolServerAPI(); $NicToolClient::VERSION = '2.40'; $NicToolClient::NTURL = 'http://www.nictool.com/'; -$NicToolClient::LICENSE = 'http://www.affero.org/oagpl.html'; -$NicToolClient::SRCURL = 'http://www.nictool.com/download/NicTool.tar.gz'; +$NicToolClient::LICENSE = 'https://www.gnu.org/licenses/agpl-3.0.html'; +$NicToolClient::SRCURL = 'https://github.com/NicTool/NicTool'; sub new { my $class = shift; @@ -64,8 +64,9 @@ sub check_setup { my $message = $server_obj->check_setup(); if ( $message ne 'OK' ) { - print $q->header; - $self->parse_template( $NicToolClient::setup_error_template, message => $message ); + print $q->header( -charset => 'utf-8', %{ $self->security_headers() } ); + $self->parse_template( $NicToolClient::setup_error_template, + message => $self->html_escape($message) ); } return $message; @@ -99,7 +100,7 @@ sub logout_user { sub display_login { my ( $self, $error ) = @_; - $self->expire_cookie(); + my $q = $self->{'CGI'}; my $message = ''; if ( ref $error ) { @@ -111,9 +112,28 @@ sub display_login { $message = $error; } - print $self->{'CGI'}->header( -charset => "utf-8" ); + my $expire_session = $q->cookie( + -name => 'NicTool', + -value => '', + -expires => '-1d', + -path => '/', + -secure => 1, + -httponly => 1, + -samesite => 'Strict', + ); + + # set a fresh CSRF token for the login form + $self->{'csrf_token'} = $self->generate_csrf_token(); + my $csrf = $self->csrf_cookie( $self->{'csrf_token'} ); - $self->parse_template( $NicToolClient::login_template, 'message' => $message ); + print $q->header( + -charset => 'utf-8', + -cookie => [ $expire_session, $csrf ], + %{ $self->security_headers() }, + ); + + $self->parse_template( $NicToolClient::login_template, + 'message' => $self->html_escape($message) ); } sub verify_session { @@ -136,14 +156,15 @@ sub verify_session { $self->expire_cookie($q); - my $redirect = 'index.cgi?message=' . $q->escape( $error_msg // q{} ); + my $escaped_msg = $q->escape($error_msg); + $escaped_msg =~ s/[^a-zA-Z0-9%._~\-]//g; # strip anything not URL-safe print qq[ - + + ]; - $self->parse_template( $NicToolClient::login_template, 'message' => $error_msg ); + $self->parse_template( $NicToolClient::login_template, + 'message' => $self->html_escape($error_msg) ); } sub set_cookie { @@ -151,29 +172,146 @@ sub set_cookie { my $q = $self->{'CGI'}; - my $cookie = $q->cookie( - -path => '/', - -name => 'NicTool', - -value => $value, - -expires => '+1M', - -secure => 1, + my $session_cookie = $q->cookie( + -path => '/', + -name => 'NicTool', + -value => $value, + -expires => '+1M', + -secure => 1, + -httponly => 1, + -samesite => 'Strict', ); - print $q->header( -cookie => $cookie ); + my $csrf = $self->csrf_cookie( $self->get_csrf_token() ); + + print $q->header( + -cookie => [ $session_cookie, $csrf ], + -charset => 'utf-8', + %{ $self->security_headers() }, + ); } sub expire_cookie { my $self = shift; my $q = shift || $self->{'CGI'}; - my $cookie = $q->cookie( - -name => 'NicTool', - -value => '', - -expires => '-1d', - -path => '/', - -secure => 1, + my $session_cookie = $q->cookie( + -name => 'NicTool', + -value => '', + -expires => '-1d', + -path => '/', + -secure => 1, + -httponly => 1, + -samesite => 'Strict', + ); + my $csrf_cookie = $q->cookie( + -name => 'NicTool_csrf', + -value => '', + -expires => '-1d', + -path => '/', + -secure => 1, + -samesite => 'Strict', + ); + print $q->header( + -cookie => [ $session_cookie, $csrf_cookie ], + -charset => 'utf-8', + %{ $self->security_headers() }, ); - print $q->header( -cookie => $cookie ); +} + +sub security_headers { + return { + '-X-Content-Type-Options' => 'nosniff', + '-X-Frame-Options' => 'SAMEORIGIN', + '-X-XSS-Protection' => '1; mode=block', + '-Referrer-Policy' => 'strict-origin-when-cross-origin', + '-Content-Security-Policy' => + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self'; frame-src 'self'; form-action 'self'; frame-ancestors 'self'", + }; +} + +sub generate_csrf_token { + my @chars = ( 'a' .. 'f', '0' .. '9' ); + my $token = ''; + for ( 1 .. 40 ) { + $token .= $chars[ rand @chars ]; + } + return $token; +} + +sub csrf_cookie { + my ( $self, $token ) = @_; + my $q = $self->{'CGI'}; + return $q->cookie( + -name => 'NicTool_csrf', + -value => $token, + -path => '/', + -secure => 1, + -httponly => 0, + -samesite => 'Strict', + ); +} + +sub get_csrf_token { + my $self = shift; + return $self->{'csrf_token'} if $self->{'csrf_token'}; + + my $q = $self->{'CGI'}; + my $token = $q->cookie('NicTool_csrf') || $self->generate_csrf_token(); + $self->{'csrf_token'} = $token; + return $token; +} + +sub csrf_hidden_field { + my $self = shift; + my $token = $self->get_csrf_token(); + return qq[]; +} + +sub verify_csrf { + my $self = shift; + my $q = $self->{'CGI'}; + + my $cookie_token = $q->cookie('NicTool_csrf'); + my $form_token = scalar( $q->param('csrf_token') ); + + if ( !$cookie_token || !$form_token || $cookie_token ne $form_token ) { + return 0; + } + return 1; +} + +sub csrf_error_page { + my $self = shift; + print + qq[
    CSRF validation failed. Please go back and try again.
    ]; + return 0; +} + +sub html_escape { + my ( $self, $str ) = @_; + return '' if !defined $str; + $str =~ s/&/&/g; + $str =~ s//>/g; + $str =~ s/"/"/g; + $str =~ s/'/'/g; + return $str; +} + +sub esc { goto &html_escape } + +sub js_escape { + my ( $self, $str ) = @_; + return '' if !defined $str; + $str =~ s/\\/\\\\/g; + $str =~ s/'/\\'/g; + $str =~ s/"/\\"/g; + $str =~ s/\n/\\n/g; + $str =~ s/\r/\\r/g; + $str =~ s/\x{2028}/\\u2028/g; + $str =~ s/\x{2029}/\\u2029/g; + return $str; } sub parse_template { @@ -183,6 +321,11 @@ sub parse_template { my %temp = @_; my $vars = \%temp; + # escape user-controlled values before template substitution + foreach my $k (qw(username groupname)) { + $vars->{$k} = $self->html_escape( $vars->{$k} ) if defined $vars->{$k}; + } + # only for stuff defined in the $NicToolClient:: namespace $self->fill_template_vars($vars); # TODO - cache # unless ($self->{'fill_vars'}); @@ -210,6 +353,9 @@ sub fill_template_vars { eval "\$temp = \$NicToolClient::$f"; $vars->{$f} = $temp; } + + $vars->{'CSRF_TOKEN_FIELD'} = $self->csrf_hidden_field(); + $vars->{'CSRF_TOKEN'} = $self->get_csrf_token(); } sub display_group_menu { @@ -223,7 +369,8 @@ sub display_group_menu { my $group = $rv->{'groups'}->[$navG]; my $gid = $group->{'nt_group_id'}; - $menu{$gid} = qq[$group->{name}]; + $menu{$gid} = + qq[] . $self->esc( $group->{name} ) . qq[]; } print qq[
    + ] . $self->csrf_hidden_field() . qq[ @@ -658,7 +811,7 @@ sub display_search_rows { -]; +] . $self->csrf_hidden_field(); foreach ( @{ $self->paging_fields }, @$state_fields ) { next if $_ eq 'page'; @@ -746,7 +899,7 @@ sub display_sort_options { print qq[
    - ]; + ] . $self->csrf_hidden_field(); foreach ( @{ $self->paging_fields }, @$state_fields ) { next if $_ =~ /sort/i; @@ -794,7 +947,7 @@ sub display_sort_options {
    -
    ]; + ] . $self->csrf_hidden_field(); foreach ( @{ $self->paging_fields }, @$state_fields ) { next if $_ eq 'edit_sortorder'; @@ -815,7 +968,7 @@ sub display_advanced_search { my @options = ( 'equals', 'contains', 'starts with', 'ends with', '<', '<=', '>', '>=' ); - print $q->start_form( -action => $cgi_name, -method => 'POST' ); + print $q->start_form( -action => $cgi_name, -method => 'POST' ), $self->csrf_hidden_field(); foreach (@$state_fields) { print $q->hidden( -name => $_ ); @@ -914,7 +1067,8 @@ sub display_advanced_search {
    ], $q->end_form, qq[
    ], - $q->start_form( -action => $cgi_name, -method => 'POST' ); + $q->start_form( -action => $cgi_name, -method => 'POST' ), + $self->csrf_hidden_field(); foreach ( @{ $self->paging_fields }, @$state_fields ) { next if $_ eq 'edit_search'; @@ -942,8 +1096,9 @@ sub display_group_list { my $group = $self->get_group( nt_group_id => scalar( $q->param('nt_group_id') ) ); if ( !$group->{'has_children'} ) { - print - qq[Group $group->{'name'} has no sub-groups!]; + print qq[Group ] + . $self->esc( $group->{'name'} ) + . qq[ has no sub-groups!]; $q->param( 'nt_group_id', $group->{'parent_group_id'} ); $group = $self->get_group( nt_group_id => scalar( $q->param('nt_group_id') ) ); } @@ -1001,6 +1156,7 @@ sub display_group_list { -method => 'POST', -name => 'new' ), + $self->csrf_hidden_field(), $q->hidden( -name => 'obj_list', -value => join( ',', $q->multi_param('obj_list') ), @@ -1066,7 +1222,9 @@ sub display_group_list { ? "&" . join( "&", map {"$_=$moreparams->{$_}"} keys %$moreparams ) : '' ) - . qq[">$_->{'name'}], + . qq[">] + . $self->esc( $_->{'name'} ) + . qq[], ( @{ $map->{ $group->{'nt_group_id'} } }, { nt_group_id => $group->{'nt_group_id'}, name => $group->{'name'} @@ -1285,15 +1443,15 @@ sub display_nice_message { my ( $self, $message, $title, $explain ) = @_; my @msgs = split( /\bAND\b/, $message ); $message = qq(
  • ) - . join( qq(
    \n
  • ), @msgs ) . '
    '; + . join( qq(
    \n
  • ), map { $self->esc($_) } @msgs ) . '
    '; print qq[
    -
    $title
    +
    ] . $self->esc($title) . qq[
    $message
    ]; if ($explain) { print qq[ -
    $explain
    ]; +
    ] . $self->esc($explain) . qq[
    ]; } print qq[
    ]; @@ -1304,13 +1462,13 @@ sub display_nice_error { my ( $self, $error, $actionmsg, $back ) = @_; my ( $message, $explain ) = @{ $self->error_message( $error->{'error_code'} ) }; my $err = $error->{'error_desc'} || 'Error'; - $actionmsg = ": " . $actionmsg if $actionmsg; + $actionmsg = ": " . $self->esc($actionmsg) if $actionmsg; my $errmsg = $error->{'error_msg'}; my @msgs = split( /\bAND\b/, $errmsg ); $errmsg = "
      \n"; foreach (@msgs) { - $errmsg .= qq[
    • $_
    • \n]; + $errmsg .= qq[
    • ] . $self->esc($_) . qq[
    • \n]; } $errmsg .= qq[
    \n
    ]; @@ -1321,8 +1479,8 @@ sub display_nice_error { print qq[
    \n - - + +
    $message$actionmsg
    $errmsg

    $explain

    ] . $self->esc($message) . qq[$actionmsg
    $errmsg

    ] . $self->esc($explain) . qq[

    $bb ($error->{'error_code'})
    ]; @@ -1333,7 +1491,7 @@ sub display_nice_error { sub display_error { my ( $self, $error ) = @_; - print qq[
    $error->{'error_msg'}
    ]; + print qq[
    ] . $self->esc( $error->{'error_msg'} ) . qq[
    ]; warn "Client error: $error->{'error_code'}: $error->{'error_msg'}: $error->{'error_desc'} " . join( ":", caller ); diff --git a/client/templates/login.html b/client/templates/login.html index 2812b924..825675a0 100644 --- a/client/templates/login.html +++ b/client/templates/login.html @@ -20,10 +20,11 @@ Password: - + + {{CSRF_TOKEN_FIELD}} diff --git a/server/t/08_nameservers.t b/server/t/08_nameservers.t index 37cc783c..04cc1a51 100644 --- a/server/t/08_nameservers.t +++ b/server/t/08_nameservers.t @@ -40,7 +40,7 @@ use Test::More 'no_plan'; use DBI; use NicToolServer::Nameserver::Sanity; -my ( $gid1, $gid2, $group1, $group2, $nsid1, $nsid2, $ns1, $ns2, @u ); +my ( $gid1, $gid2, $group1, $group2, $nsid1, $nsid2, $ns1, $ns2, @u, $res ); my ( %name, %address, %ttl, %export_format ); my $user = nt_api_connect(); diff --git a/server/t/10_zones.t b/server/t/10_zones.t index c2dcd4c9..2815ad6a 100644 --- a/server/t/10_zones.t +++ b/server/t/10_zones.t @@ -41,7 +41,7 @@ use Test::More 'no_plan'; my $user = nt_api_connect(); -my ( $group1, $group2, $gid1, $gid2, $nsid1, $nsid2, $zid1, $zid2 ); +my ( $group1, $group2, $gid1, $gid2, $nsid1, $nsid2, $zid1, $zid2, $res ); my ( $rzid1, $rzid2, %z1, %z2, $zone1, $zone2, $n1, $n2, $z, @z ); # try to do the tests From 67e707efdd6c090e933307ee514c5295a74ed764 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:19:14 +0100 Subject: [PATCH 2/2] test: add comprehensive Playwright E2E test suite Add 120 end-to-end tests covering the full NicTool web client: - Authentication (login, logout, session persistence) - CRUD operations (groups, zones, records, users, nameservers) - Delegation (zone/record delegation with permission variants) - Navigation (frameset, nav tree, section links) - Permissions (per-feature deny checks, self_write) - Search, sort, and pagination - Move operations (zones, users, nameservers between groups) - Log views (group, zone, record logs) - Security (CSRF enforcement, XSS protection, CSP compliance, cookie flags, security headers) - Delete UI (trash icon links with CSRF tokens) Co-Authored-By: Claude Opus 4.6 (1M context) --- client/t/e2e/.gitignore | 2 + client/t/e2e/auth.spec.ts | 89 ++++++++ client/t/e2e/delegation.spec.ts | 138 ++++++++++++ client/t/e2e/delete-ui.spec.ts | 228 ++++++++++++++++++++ client/t/e2e/groups.spec.ts | 108 ++++++++++ client/t/e2e/helpers.ts | 304 ++++++++++++++++++++++++++ client/t/e2e/logs.spec.ts | 101 +++++++++ client/t/e2e/move.spec.ts | 149 +++++++++++++ client/t/e2e/nameservers.spec.ts | 130 ++++++++++++ client/t/e2e/navigation.spec.ts | 117 ++++++++++ client/t/e2e/package-lock.json | 72 +++++++ client/t/e2e/package.json | 10 + client/t/e2e/permissions.spec.ts | 174 +++++++++++++++ client/t/e2e/playwright.config.ts | 14 ++ client/t/e2e/records.spec.ts | 300 ++++++++++++++++++++++++++ client/t/e2e/search-sort.spec.ts | 102 +++++++++ client/t/e2e/security.spec.ts | 340 ++++++++++++++++++++++++++++++ client/t/e2e/users.spec.ts | 124 +++++++++++ client/t/e2e/zones.spec.ts | 168 +++++++++++++++ 19 files changed, 2670 insertions(+) create mode 100644 client/t/e2e/.gitignore create mode 100644 client/t/e2e/auth.spec.ts create mode 100644 client/t/e2e/delegation.spec.ts create mode 100644 client/t/e2e/delete-ui.spec.ts create mode 100644 client/t/e2e/groups.spec.ts create mode 100644 client/t/e2e/helpers.ts create mode 100644 client/t/e2e/logs.spec.ts create mode 100644 client/t/e2e/move.spec.ts create mode 100644 client/t/e2e/nameservers.spec.ts create mode 100644 client/t/e2e/navigation.spec.ts create mode 100644 client/t/e2e/package-lock.json create mode 100644 client/t/e2e/package.json create mode 100644 client/t/e2e/permissions.spec.ts create mode 100644 client/t/e2e/playwright.config.ts create mode 100644 client/t/e2e/records.spec.ts create mode 100644 client/t/e2e/search-sort.spec.ts create mode 100644 client/t/e2e/security.spec.ts create mode 100644 client/t/e2e/users.spec.ts create mode 100644 client/t/e2e/zones.spec.ts diff --git a/client/t/e2e/.gitignore b/client/t/e2e/.gitignore new file mode 100644 index 00000000..ae2f5329 --- /dev/null +++ b/client/t/e2e/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +test-results/ diff --git a/client/t/e2e/auth.spec.ts b/client/t/e2e/auth.spec.ts new file mode 100644 index 00000000..1c43b6f8 --- /dev/null +++ b/client/t/e2e/auth.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, USERNAME, PASSWORD, + freshCtx, getLoginCsrf, apiLogin, authGet, cookieString, +} from './helpers'; + +test.describe('Authentication', () => { + test('successful login returns frameset with session cookie', async ({ playwright }) => { + const { sessionCookie, body } = await apiLogin(playwright); + expect(sessionCookie).toBeTruthy(); + expect(sessionCookie.length).toBeGreaterThan(0); + expect(body.toLowerCase()).toContain(' { + const ctx = await freshCtx(playwright); + const { csrfToken, csrfCookie } = await getLoginCsrf(ctx); + + const res = await ctx.post(`${BASE}/index.cgi`, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': `NicTool_csrf=${csrfCookie}`, + }, + data: `username=${USERNAME}&password=wrongpassword&login=Enter&csrf_token=${csrfToken}`, + }); + + const body = await res.text(); + const setCookies = res.headersArray().filter(h => h.name.toLowerCase() === 'set-cookie'); + const sessionHeader = setCookies.find(h => /^NicTool=[^;]/.test(h.value) && !h.value.startsWith('NicTool_csrf')); + + expect(body.toLowerCase()).not.toContain(' { + const ctx = await freshCtx(playwright); + const { csrfToken, csrfCookie } = await getLoginCsrf(ctx); + + const res = await ctx.post(`${BASE}/index.cgi`, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': `NicTool_csrf=${csrfCookie}`, + }, + data: `username=&password=&login=Enter&csrf_token=${csrfToken}`, + }); + + const body = await res.text(); + expect(body.toLowerCase()).not.toContain(' { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + const cookies = cookieString(sessionCookie, csrfCookie); + + // Verify session works first + const { body: before } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(before).toContain('NicTool'); + + // Logout + await authGet(playwright, `${BASE}/index.cgi?logout=1`, cookies); + + // Session should no longer work - should get login page or redirect + const { body: after } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(after.toLowerCase()).toMatch(/login|username|password|error|session/); + }); + + test('invalid session cookie gets no authenticated content', async ({ playwright }) => { + const cookies = cookieString('invalidsessioncookie123', 'invalidcsrf123'); + const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(body.toLowerCase()).toMatch(/login|username|password|error|session/); + }); + + test('session persists across multiple requests', async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + const cookies = cookieString(sessionCookie, csrfCookie); + + const { body: r1 } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(r1).toContain('NicTool'); + + const { body: r2 } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(r2).toContain('NicTool'); + + const { body: r3 } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(r3).toContain('NicTool'); + }); +}); diff --git a/client/t/e2e/delegation.spec.ts b/client/t/e2e/delegation.spec.ts new file mode 100644 index 00000000..deda55c8 --- /dev/null +++ b/client/t/e2e/delegation.spec.ts @@ -0,0 +1,138 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, GROUP_DEFAULTS, + apiLogin, authGet, authPost, cookieString, + createGroup, deleteGroup, createZone, deleteZone, + createRecord, deleteRecord, createUser, deleteUser, + uniqueName, extractCsrf, +} from './helpers'; + +test.describe('Delegation', () => { + let cookies: string; + let csrfToken: string; + let parentGid: string; + let childGid: string; + let childGroupName: string; + let zid: string; + let zoneName: string; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + cookies = cookieString(sessionCookie, csrfCookie); + csrfToken = csrfCookie; + + parentGid = await createGroup(playwright, cookies, 1, uniqueName('e2e_deleg_parent')); + childGroupName = uniqueName('e2e_deleg_child'); + childGid = await createGroup(playwright, cookies, parentGid, childGroupName); + zoneName = `${uniqueName('e2e-deleg')}.test`; + zid = await createZone(playwright, cookies, parentGid, zoneName); + }); + + test.afterAll(async ({ playwright }) => { + await deleteZone(playwright, cookies, parentGid, zid); + await deleteGroup(playwright, cookies, parentGid, childGid); + await deleteGroup(playwright, cookies, 1, parentGid); + }); + + test('delegate zone to child group', async ({ playwright }) => { + // Save=Save, group_list=target, obj_list=zid, type=zone, permissions + const { body } = await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Save=Save&group_list=${childGid}&obj_list=${zid}&type=zone&perm_write=1&perm_delete=0&perm_delegate=0&zone_perm_add_records=1&zone_perm_delete_records=1&csrf_token=${csrfToken}`); + expect(body.toLowerCase()).not.toContain('error_code'); + }); + + test('delegated zone appears in child group zone list', async ({ playwright }) => { + const { body } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${childGid}`, cookies); + expect(body).toContain(zoneName); + }); + + test('delegated zone shows delegation info', async ({ playwright }) => { + const { body } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${childGid}`, cookies); + expect(body).toContain(zoneName); + }); + + test('edit zone delegation permissions', async ({ playwright }) => { + // Modify=Modify to edit existing delegation + const { body } = await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Modify=Modify&nt_group_id=${childGid}&obj_list=${zid}&type=zone&perm_write=0&perm_delete=0&perm_delegate=0&zone_perm_add_records=0&zone_perm_delete_records=0&csrf_token=${csrfToken}`); + expect(body.toLowerCase()).not.toContain('error_code'); + }); + + test('remove zone delegation', async ({ playwright }) => { + // Re-delegate first + await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Save=Save&group_list=${childGid}&obj_list=${zid}&type=zone&perm_write=1&perm_delete=0&perm_delegate=0&zone_perm_add_records=1&zone_perm_delete_records=1&csrf_token=${csrfToken}`); + + // Remove=Remove + const { body } = await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Remove=Remove&nt_group_id=${childGid}&nt_zone_id=${zid}&csrf_token=${csrfToken}`); + expect(body.toLowerCase()).not.toContain('error_code'); + + // Re-delegate for subsequent tests + await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Save=Save&group_list=${childGid}&obj_list=${zid}&type=zone&perm_write=1&perm_delete=0&perm_delegate=0&zone_perm_add_records=1&zone_perm_delete_records=1&csrf_token=${csrfToken}`); + }); + + test('delegate record to child group', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, parentGid, zid, + { name: 'deleg-rec', type: 'A', address: '192.0.2.30' }); + + try { + const { body } = await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Save=Save&group_list=${childGid}&obj_list=${rrid}&type=record&perm_write=1&perm_delete=0&perm_delegate=0&csrf_token=${csrfToken}`); + expect(body.toLowerCase()).not.toContain('error_code'); + } finally { + await deleteRecord(playwright, cookies, parentGid, zid, rrid); + } + }); + + test('delegation with write perm allows editing', async ({ playwright }) => { + const username = uniqueName('e2e_deleg_user'); + const uid = await createUser(playwright, cookies, childGid, { username, password: 'delegtest123!' }); + + try { + // Ensure zone is delegated with write + add_records permission + await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Save=Save&group_list=${childGid}&obj_list=${zid}&type=zone&perm_write=1&perm_delete=0&perm_delegate=0&zone_perm_add_records=1&zone_perm_delete_records=1&csrf_token=${csrfToken}`); + + // Login as child user + const childLogin = await apiLogin(playwright, `${username}@${childGroupName}`, 'delegtest123!'); + const childCookies = cookieString(childLogin.sessionCookie, childLogin.csrfCookie); + + // Should be able to create a record in the delegated zone + const rrid = await createRecord(playwright, childCookies, childGid, zid, + { name: 'deleg-write', type: 'A', address: '192.0.2.31' }); + + await deleteRecord(playwright, cookies, parentGid, zid, rrid); + } finally { + await deleteUser(playwright, cookies, childGid, uid); + } + }); + + test('delegation without write perm prevents editing', async ({ playwright }) => { + const username = uniqueName('e2e_deleg_nowrite'); + const uid = await createUser(playwright, cookies, childGid, { username, password: 'delegtest123!' }); + + try { + // Delegate zone with NO write, NO add_records permission + await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Modify=Modify&nt_group_id=${childGid}&obj_list=${zid}&type=zone&perm_write=0&perm_delete=0&perm_delegate=0&zone_perm_add_records=0&zone_perm_delete_records=0&csrf_token=${csrfToken}`); + + // Login as child user + const childLogin = await apiLogin(playwright, `${username}@${childGroupName}`, 'delegtest123!'); + const childCookies = cookieString(childLogin.sessionCookie, childLogin.csrfCookie); + + // Trying to add a record should fail + const { body } = await authPost(playwright, `${BASE}/zone.cgi`, childCookies, + `nt_group_id=${childGid}&nt_zone_id=${zid}&new_record=1&Create=Create&name=nowrite&type=A&address=192.0.2.32&ttl=3600&csrf_token=${childLogin.csrfCookie}`); + expect(body.toLowerCase()).toMatch(/error|permission|denied|not allowed|access/i); + } finally { + await deleteUser(playwright, cookies, childGid, uid); + // Restore delegation with write + await authPost(playwright, `${BASE}/delegate_zones.cgi`, cookies, + `Modify=Modify&nt_group_id=${childGid}&obj_list=${zid}&type=zone&perm_write=1&perm_delete=0&perm_delegate=0&zone_perm_add_records=1&zone_perm_delete_records=1&csrf_token=${csrfToken}`); + } + }); +}); diff --git a/client/t/e2e/delete-ui.spec.ts b/client/t/e2e/delete-ui.spec.ts new file mode 100644 index 00000000..7c703adb --- /dev/null +++ b/client/t/e2e/delete-ui.spec.ts @@ -0,0 +1,228 @@ +import { test, expect } from '@playwright/test'; +import { + apiLogin, cookieString, authGet, authPost, + createGroup, createZone, createRecord, createUser, createNameserver, + deleteGroup, deleteZone, deleteRecord, deleteUser, deleteNameserver, + uniqueName, uniqueNsName, extractCsrf, BASE, +} from './helpers'; + +// --------------------------------------------------------------------------- +// These tests verify that the delete icons rendered in the UI actually work. +// They fetch the listing page, extract the real delete link/form as rendered +// in the HTML, follow it exactly as the browser would, and confirm the delete +// succeeds (no CSRF error, entity is removed). +// +// This catches the bug where trash-icon links omit csrf_token. +// --------------------------------------------------------------------------- + +// ---- Helpers to extract delete links/forms from rendered HTML ---- + +/** Extract the trash-icon delete href for a group from group.cgi HTML */ +function extractGroupDeleteHref(html: string, gid: string): string | null { + // Pattern: + const re = new RegExp(`]*>\\s*]*trash\\.gif`); + const m = html.match(re); + return m ? m[1].replace(/&/g, '&') : null; +} + +/** Extract the trash-icon delete href for a user from group_users.cgi HTML */ +function extractUserDeleteHref(html: string, uid: string): string | null { + const re = new RegExp(`]*>\\s*]*trash\\.gif`); + const m = html.match(re); + if (m) return m[1].replace(/&/g, '&'); + // Try alternate order: obj_list before delete + const re2 = new RegExp(`]*>\\s*]*trash\\.gif`); + const m2 = html.match(re2); + return m2 ? m2[1].replace(/&/g, '&') : null; +} + +/** Extract the trash-icon delete href for a nameserver from group_nameservers.cgi HTML */ +function extractNameserverDeleteHref(html: string, nsid: string): string | null { + const re = new RegExp(`]*>\\s*]*trash\\.gif`); + const m = html.match(re); + if (m) return m[1].replace(/&/g, '&'); + const re2 = new RegExp(`]*>\\s*]*trash\\.gif`); + const m2 = html.match(re2); + return m2 ? m2[1].replace(/&/g, '&') : null; +} + +/** Extract the trash-icon delete href for a zone from group_zones.cgi HTML */ +function extractZoneDeleteHref(html: string, zid: string): string | null { + const re = new RegExp(`]*>\\s*]*trash\\.gif`); + const m = html.match(re); + if (m) return m[1].replace(/&/g, '&'); + const re2 = new RegExp(`]*>\\s*]*trash\\.gif`); + const m2 = html.match(re2); + return m2 ? m2[1].replace(/&/g, '&') : null; +} + +/** Extract the delete form for a record from zone.cgi HTML */ +function extractRecordDeleteForm(html: string, rrid: string): { action: string; fields: Record } | null { + // Look for a form containing delete_record with the given rrid + const formRe = new RegExp( + `]*method="post"[^>]*action="([^"]*)"[^>]*>([\\s\\S]*?)`, + 'gi' + ); + let match; + while ((match = formRe.exec(html)) !== null) { + const [, action, formBody] = match; + if (formBody.includes(`name="delete_record"`) && formBody.includes(`value="${rrid}"`)) { + const fields: Record = {}; + const inputRe = /name="([^"]+)"\s+value="([^"]*)"/g; + let im; + while ((im = inputRe.exec(formBody)) !== null) { + fields[im[1]] = im[2]; + } + // Also check value="..." name="..." order + const inputRe2 = /value="([^"]*)"\s+name="([^"]+)"/g; + while ((im = inputRe2.exec(formBody)) !== null) { + if (!fields[im[2]]) fields[im[2]] = im[1]; + } + return { action: action.replace(/&/g, '&'), fields }; + } + } + return null; +} + + +test.describe('Delete via UI trash icon', () => { + let cookies: string; + let csrfCookie: string; + + test.beforeAll(async ({ playwright }) => { + const login = await apiLogin(playwright); + cookies = cookieString(login.sessionCookie, login.csrfCookie); + csrfCookie = login.csrfCookie; + }); + + test('delete group via rendered trash icon link', async ({ playwright }) => { + const gid = await createGroup(playwright, cookies, 1); + + // Fetch the group listing page as the browser would + const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + + // Extract the actual delete link from the HTML + const href = extractGroupDeleteHref(body, gid); + expect(href, 'trash icon link should exist for the group').toBeTruthy(); + + // The link MUST contain csrf_token for CSRF protection to pass + expect(href, 'delete link must include csrf_token').toContain('csrf_token'); + + // Follow the link exactly as the browser would + const { body: afterBody, res } = await authGet(playwright, `${BASE}/${href}`, cookies); + + // Should NOT show CSRF error + expect(afterBody).not.toContain('CSRF validation failed'); + + // Group should be gone from listing + const { body: listBody } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(listBody).not.toContain(`nt_group_id=${gid}"`); + }); + + test('delete user via rendered trash icon link', async ({ playwright }) => { + const gid = await createGroup(playwright, cookies, 1); + const username = uniqueName('deluiusr'); + const uid = await createUser(playwright, cookies, gid, { username }); + + // Fetch the user listing page + const { body } = await authGet(playwright, `${BASE}/group_users.cgi?nt_group_id=${gid}`, cookies); + + // Extract the actual delete link + const href = extractUserDeleteHref(body, uid); + expect(href, 'trash icon link should exist for the user').toBeTruthy(); + expect(href, 'delete link must include csrf_token').toContain('csrf_token'); + + // Follow the link + const { body: afterBody } = await authGet(playwright, `${BASE}/${href}`, cookies); + expect(afterBody).not.toContain('CSRF validation failed'); + + // User should be gone + const { body: listBody } = await authGet(playwright, `${BASE}/group_users.cgi?nt_group_id=${gid}`, cookies); + expect(listBody).not.toContain(username); + + // Cleanup + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('delete nameserver via rendered trash icon link', async ({ playwright }) => { + // Create nameserver in root group (gid=1) which has usable nameservers + const nsName = uniqueNsName('deluins') + '.example.com.'; + const nsid = await createNameserver(playwright, cookies, 1, { name: nsName }); + + // Fetch the nameserver listing page + const { body } = await authGet(playwright, `${BASE}/group_nameservers.cgi?nt_group_id=1`, cookies); + + // Extract the actual delete link + const href = extractNameserverDeleteHref(body, nsid); + expect(href, 'trash icon link should exist for the nameserver').toBeTruthy(); + expect(href, 'delete link must include csrf_token').toContain('csrf_token'); + + // Follow the link + const { body: afterBody } = await authGet(playwright, `${BASE}/${href}`, cookies); + expect(afterBody).not.toContain('CSRF validation failed'); + + // Nameserver should be gone + const { body: listBody } = await authGet(playwright, `${BASE}/group_nameservers.cgi?nt_group_id=1`, cookies); + expect(listBody).not.toContain(nsName); + }); + + test('delete zone via rendered trash icon link', async ({ playwright }) => { + const gid = await createGroup(playwright, cookies, 1); + const zoneName = `${uniqueName('deluizn')}.test`; + const zid = await createZone(playwright, cookies, gid, zoneName); + + // Fetch the zone listing page + const { body } = await authGet(playwright, `${BASE}/group_zones.cgi?nt_group_id=${gid}`, cookies); + + // Extract the actual delete link + const href = extractZoneDeleteHref(body, zid); + expect(href, 'trash icon link should exist for the zone').toBeTruthy(); + expect(href, 'delete link must include csrf_token').toContain('csrf_token'); + + // Follow the link + const { body: afterBody } = await authGet(playwright, `${BASE}/${href}`, cookies); + expect(afterBody).not.toContain('CSRF validation failed'); + + // Zone should be gone + const { body: listBody } = await authGet(playwright, `${BASE}/group_zones.cgi?nt_group_id=${gid}`, cookies); + expect(listBody).not.toContain(zoneName); + + // Cleanup + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('delete record via rendered trash form submit', async ({ playwright }) => { + const gid = await createGroup(playwright, cookies, 1); + const zoneName = `${uniqueName('deluirr')}.test`; + const zid = await createZone(playwright, cookies, gid, zoneName); + const rrid = await createRecord(playwright, cookies, gid, zid, { + name: 'deltest', type: 'A', address: '10.0.0.99', + }); + + // Fetch the zone detail page (which lists records) + const { body } = await authGet(playwright, + `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + + // Extract the delete form for this record + const form = extractRecordDeleteForm(body, rrid); + expect(form, 'delete form should exist for the record').toBeTruthy(); + expect(form!.fields, 'delete form must include csrf_token').toHaveProperty('csrf_token'); + expect(form!.fields['csrf_token']).toBeTruthy(); + + // Submit the form exactly as the browser would + const formData = Object.entries(form!.fields).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&'); + const { body: afterBody } = await authPost(playwright, + `${BASE}/${form!.action}`, cookies, formData); + + expect(afterBody).not.toContain('CSRF validation failed'); + + // Record should be gone + const { body: listBody } = await authGet(playwright, + `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(listBody).not.toContain(`nt_zone_record_id=${rrid}`); + + // Cleanup + await deleteZone(playwright, cookies, gid, zid); + await deleteGroup(playwright, cookies, 1, gid); + }); +}); diff --git a/client/t/e2e/groups.spec.ts b/client/t/e2e/groups.spec.ts new file mode 100644 index 00000000..d9e063af --- /dev/null +++ b/client/t/e2e/groups.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, GROUP_DEFAULTS, + apiLogin, authGet, authPost, cookieString, + createGroup, deleteGroup, uniqueName, extractCsrf, +} from './helpers'; + +test.describe('Groups', () => { + let cookies: string; + let csrfToken: string; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + cookies = cookieString(sessionCookie, csrfCookie); + csrfToken = csrfCookie; + }); + + test('create sub-group with all permissions', async ({ playwright }) => { + const name = uniqueName('e2e_grp'); + const gid = await createGroup(playwright, cookies, 1, name); + expect(Number(gid)).toBeGreaterThan(0); + + // Cleanup + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('sub-group appears in group list', async ({ playwright }) => { + const name = uniqueName('e2e_grp'); + const gid = await createGroup(playwright, cookies, 1, name); + + const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(body).toContain(name); + + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('edit sub-group name', async ({ playwright }) => { + const name = uniqueName('e2e_grp'); + const gid = await createGroup(playwright, cookies, 1, name); + + const newName = uniqueName('e2e_renamed'); + await authPost(playwright, `${BASE}/group.cgi`, cookies, + `nt_group_id=${gid}&edit=1&Save=Save&name=${newName}&${GROUP_DEFAULTS}&csrf_token=${csrfToken}`); + + const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(body).toContain(newName); + + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('edit sub-group permissions', async ({ playwright }) => { + const name = uniqueName('e2e_grp'); + const gid = await createGroup(playwright, cookies, 1, name); + + // Edit to remove zone_create permission + const reducedPerms = GROUP_DEFAULTS.replace('zone_create=1', 'zone_create=0'); + await authPost(playwright, `${BASE}/group.cgi`, cookies, + `nt_group_id=${gid}&edit=1&Save=Save&name=${name}&${reducedPerms}&csrf_token=${csrfToken}`); + + // Verify edit page shows the group + const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=${gid}&edit=1`, cookies); + expect(body).toContain(name); + + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('delete sub-group', async ({ playwright }) => { + const name = uniqueName('e2e_grp'); + const gid = await createGroup(playwright, cookies, 1, name); + + await deleteGroup(playwright, cookies, 1, gid); + + const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(body).not.toContain(name); + }); + + test('create group without name fails gracefully', async ({ playwright }) => { + const { body } = await authPost(playwright, `${BASE}/group.cgi`, cookies, + `nt_group_id=1&new=1&Create=Create&name=&${GROUP_DEFAULTS}&csrf_token=${csrfToken}`); + // Should show error or remain on form, not crash + expect(body.toLowerCase()).toMatch(/error|required|invalid|group/i); + }); + + test('quick zone search from group page', async ({ playwright }) => { + // The group page has a zone quick-search. Verify it responds. + const { body } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=1&Quick+search=Search&search_value=nonexistent`, cookies); + expect(body).toBeDefined(); + // Should not crash even with no results + expect(body.toLowerCase()).not.toContain('internal server error'); + }); + + test('nested sub-group creation and deletion', async ({ playwright }) => { + const parentName = uniqueName('e2e_parent'); + const parentGid = await createGroup(playwright, cookies, 1, parentName); + + const childName = uniqueName('e2e_child'); + const childGid = await createGroup(playwright, cookies, parentGid, childName); + + // Verify child exists in parent + const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=${parentGid}`, cookies); + expect(body).toContain(childName); + + // Delete child first, then parent + await deleteGroup(playwright, cookies, parentGid, childGid); + await deleteGroup(playwright, cookies, 1, parentGid); + }); +}); diff --git a/client/t/e2e/helpers.ts b/client/t/e2e/helpers.ts new file mode 100644 index 00000000..5a05a220 --- /dev/null +++ b/client/t/e2e/helpers.ts @@ -0,0 +1,304 @@ +import { type APIRequestContext } from '@playwright/test'; +import type { Page, Frame } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +export const BASE = process.env.NICTOOL_URL || 'https://localhost:8443'; +export const USERNAME = 'root'; +export const PASSWORD = 'nictool'; + +export const GROUP_DEFAULTS = [ + 'user_create=1', 'user_delete=1', 'user_write=1', + 'group_create=1', 'group_delete=1', 'group_write=1', + 'zone_create=1', 'zone_delegate=1', 'zone_delete=1', 'zone_write=1', + 'zonerecord_create=1', 'zonerecord_delegate=1', 'zonerecord_delete=1', 'zonerecord_write=1', + 'nameserver_create=1', 'nameserver_delete=1', 'nameserver_write=1', + 'self_write=1', + 'usable_nameservers=1', 'usable_nameservers=2', 'usable_nameservers=3', +].join('&'); + +// --------------------------------------------------------------------------- +// API Helpers +// --------------------------------------------------------------------------- + +export async function freshCtx(playwright: any): Promise { + return await playwright.request.newContext({ ignoreHTTPSErrors: true }); +} + +export async function getLoginCsrf(ctx: APIRequestContext) { + const res = await ctx.get(`${BASE}/index.cgi`); + const body = await res.text(); + const match = body.match(/name="csrf_token"\s+value="([^"]+)"/); + const csrfToken = match ? match[1] : ''; + + const setCookies = res.headersArray().filter(h => h.name.toLowerCase() === 'set-cookie'); + let csrfCookie = ''; + for (const sc of setCookies) { + const m = sc.value.match(/NicTool_csrf=([^;]+)/); + if (m && m[1]) { csrfCookie = m[1]; break; } + } + + return { csrfToken, csrfCookie, body, response: res }; +} + +export async function apiLogin(playwright: any, username = USERNAME, password = PASSWORD) { + const ctx = await freshCtx(playwright); + const { csrfToken, csrfCookie } = await getLoginCsrf(ctx); + + const res = await ctx.post(`${BASE}/index.cgi`, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': `NicTool_csrf=${csrfCookie}`, + }, + data: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&login=Enter&csrf_token=${csrfToken}`, + }); + + const body = await res.text(); + const headers = res.headers(); + const setCookieHeaders = res.headersArray().filter(h => h.name.toLowerCase() === 'set-cookie'); + let sessionCookie = ''; + let newCsrfCookie = ''; + for (const sc of setCookieHeaders) { + const sm = sc.value.match(/^NicTool=([^;]+)/); + if (sm && sm[1]) sessionCookie = sm[1]; + const cm = sc.value.match(/NicTool_csrf=([^;]+)/); + if (cm && cm[1]) newCsrfCookie = cm[1]; + } + + await ctx.dispose(); + return { sessionCookie, csrfCookie: newCsrfCookie || csrfCookie, body, headers, setCookieHeaders }; +} + +export function cookieString(sessionCookie: string, csrfCookie: string): string { + return `NicTool=${sessionCookie}; NicTool_csrf=${csrfCookie}`; +} + +export async function authGet(playwright: any, url: string, cookies: string) { + const ctx = await freshCtx(playwright); + const res = await ctx.get(url, { headers: { Cookie: cookies } }); + const body = await res.text(); + await ctx.dispose(); + return { res, body }; +} + +export async function authPost(playwright: any, url: string, cookies: string, data: string) { + const ctx = await freshCtx(playwright); + const res = await ctx.post(url, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': cookies }, + data, + }); + const body = await res.text(); + await ctx.dispose(); + return { res, body }; +} + +export function expectSecurityHeaders(headers: Record) { + const { expect } = require('@playwright/test'); + expect(headers['content-security-policy']).toContain("default-src 'self'"); + expect(headers['x-content-type-options']).toContain('nosniff'); + expect(headers['x-frame-options']).toContain('SAMEORIGIN'); + expect(headers['x-xss-protection']).toContain('1; mode=block'); + expect(headers['referrer-policy']).toContain('strict-origin-when-cross-origin'); +} + +// --------------------------------------------------------------------------- +// Browser Helpers +// --------------------------------------------------------------------------- + +export async function browserLogin(page: Page) { + await page.goto(`${BASE}/index.cgi`); + await page.fill('input[name="username"]', USERNAME); + await page.fill('input[name="password"]', PASSWORD); + await page.click('input[type="submit"][name="login"]'); + await page.waitForTimeout(3000); +} + +export function collectViolations(page: Page) { + const violations: string[] = []; + page.on('console', (msg) => { + const text = msg.text(); + if (text.includes('Content Security Policy') || text.includes('Refused to')) { + violations.push(text); + } + }); + page.on('pageerror', (err) => { + violations.push(`PAGE ERROR: ${err.message}`); + }); + return violations; +} + +export function getBodyFrame(page: Page): Frame | undefined { + return page.frames().find(f => f.url().includes('group.cgi') || f.url().includes('group_zones.cgi') || f.url().includes('group_users.cgi') || f.url().includes('group_nameservers.cgi') || f.url().includes('group_log.cgi') || f.url().includes('zone.cgi')); +} + +export function getNavFrame(page: Page): Frame | undefined { + return page.frames().find(f => f.url().includes('nav.cgi')); +} + +// --------------------------------------------------------------------------- +// Unique Name Generator +// --------------------------------------------------------------------------- + +let _counter = 0; +export function uniqueName(prefix: string): string { + return `${prefix}_${Date.now()}_${++_counter}`; +} + +// For nameserver names which cannot contain underscores +export function uniqueNsName(prefix: string): string { + return `${prefix}-${Date.now()}-${++_counter}`; +} + +// --------------------------------------------------------------------------- +// CRUD Factory Helpers +// --------------------------------------------------------------------------- + +// Helper to find an entity ID on a listing page by name. +// Handles HTML-encoded ampersands and whitespace around names. +function findIdInBody(body: string, idParam: string, name: string): string | null { + // Try specific match: idParam=(\d+) followed by the name somewhere nearby + const escaped = escapeRegex(name); + // Pattern 1: direct link like nt_group_id=71">groupname + const m1 = body.match(new RegExp(`${idParam}=(\\d+)">${escaped}`)); + if (m1) return m1[1]; + // Pattern 2: link with & params, then name with possible whitespace + const m2 = body.match(new RegExp(`${idParam}=(\\d+)&[^>]*>[\\s]*(?:<[^>]+>\\s*)*${escaped}`)); + if (m2) return m2[1]; + // Pattern 3: link with whitespace before name + const m3 = body.match(new RegExp(`${idParam}=(\\d+)"[^>]*>[\\s]*${escaped}`)); + if (m3) return m3[1]; + // Pattern 4: name appears after img tag + const m4 = body.match(new RegExp(`${idParam}=(\\d+)[^>]*>\\s*]*>\\s*${escaped}`)); + if (m4) return m4[1]; + return null; +} + +export async function createGroup(playwright: any, cookies: string, parentGid: string | number, name?: string): Promise { + const groupName = name || uniqueName('e2e_grp'); + await authPost(playwright, `${BASE}/group.cgi`, cookies, + `nt_group_id=${parentGid}&new=1&Create=Create&name=${groupName}&${GROUP_DEFAULTS}&csrf_token=${extractCsrf(cookies)}`); + + // Check multiple pages since NicTool hardcodes 10 items per page + for (let page = 1; page <= 10; page++) { + const { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=${parentGid}&page=${page}`, cookies); + const gid = findIdInBody(body, 'nt_group_id', groupName); + if (gid) return gid; + // If no "next page" link, stop searching + if (!body.includes('page=' + (page + 1))) break; + } + throw new Error(`Failed to find created group "${groupName}" in parent ${parentGid}`); +} + +export async function deleteGroup(playwright: any, cookies: string, parentGid: string | number, gid: string | number): Promise { + await authGet(playwright, `${BASE}/group.cgi?nt_group_id=${parentGid}&delete=${gid}&csrf_token=${extractCsrf(cookies)}`, cookies); +} + +export async function createZone(playwright: any, cookies: string, gid: string | number, zoneName?: string): Promise { + const zone = zoneName || `${uniqueName('e2e')}.test`; + await authPost(playwright, `${BASE}/group_zones.cgi`, cookies, + `nt_group_id=${gid}&new=1&Create=Create&zone=${zone}&mailaddr=admin.${zone}&description=e2e+test&ttl=3600&refresh=16384&retry=2048&expire=1048576&minimum=2560&csrf_token=${extractCsrf(cookies)}`); + + const { body } = await authGet(playwright, `${BASE}/group_zones.cgi?nt_group_id=${gid}&limit=255`, cookies); + const zid = findIdInBody(body, 'nt_zone_id', zone); + if (!zid) { + // Fallback: find any zone ID on the page + const m2 = body.match(/nt_zone_id=(\d+)/); + if (!m2) throw new Error(`Failed to find created zone "${zone}" in group ${gid}`); + return m2[1]; + } + return zid; +} + +export async function deleteZone(playwright: any, cookies: string, gid: string | number, zid: string | number): Promise { + await authPost(playwright, `${BASE}/group_zones.cgi`, cookies, + `nt_group_id=${gid}&delete=1&zone_list=${zid}&csrf_token=${extractCsrf(cookies)}`); +} + +export async function createRecord(playwright: any, cookies: string, gid: string | number, zid: string | number, + opts: { name: string; type: string; address: string; ttl?: number; weight?: string; priority?: string; other?: string; description?: string } +): Promise { + let data = `nt_group_id=${gid}&nt_zone_id=${zid}&new_record=1&Create=Create&name=${encodeURIComponent(opts.name)}&type=${opts.type}&address=${encodeURIComponent(opts.address)}&ttl=${opts.ttl || 3600}&csrf_token=${extractCsrf(cookies)}`; + if (opts.weight !== undefined) data += `&weight=${encodeURIComponent(opts.weight)}`; + if (opts.priority !== undefined) data += `&priority=${encodeURIComponent(opts.priority)}`; + if (opts.other !== undefined) data += `&other=${encodeURIComponent(opts.other)}`; + if (opts.description !== undefined) data += `&description=${encodeURIComponent(opts.description)}`; + + const { body } = await authPost(playwright, `${BASE}/zone.cgi`, cookies, data); + const m = body.match(/nt_zone_record_id=(\d+)/); + if (!m) throw new Error(`Failed to create record ${opts.type} "${opts.name}" in zone ${zid}. Body snippet: ${body.substring(0, 500)}`); + return m[1]; +} + +export async function deleteRecord(playwright: any, cookies: string, gid: string | number, zid: string | number, rrid: string | number): Promise { + await authPost(playwright, `${BASE}/zone.cgi`, cookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&nt_zone_record_id=${rrid}&delete_record=${rrid}&csrf_token=${extractCsrf(cookies)}`); +} + +export async function createUser(playwright: any, cookies: string, gid: string | number, + opts: { username: string; password?: string; email?: string; first_name?: string; last_name?: string } +): Promise { + const pw = opts.password || 'testpass123!'; + const email = opts.email || `${opts.username}@test.example`; + const first = opts.first_name || 'Test'; + const last = opts.last_name || 'User'; + await authPost(playwright, `${BASE}/group_users.cgi`, cookies, + `nt_group_id=${gid}&new=1&Create=Create&username=${encodeURIComponent(opts.username)}&password=${encodeURIComponent(pw)}&password2=${encodeURIComponent(pw)}&email=${encodeURIComponent(email)}&first_name=${encodeURIComponent(first)}&last_name=${encodeURIComponent(last)}&group_defaults=1&csrf_token=${extractCsrf(cookies)}`); + + const { body } = await authGet(playwright, `${BASE}/group_users.cgi?nt_group_id=${gid}&limit=255`, cookies); + const uid = findIdInBody(body, 'nt_user_id', opts.username); + if (!uid) { + // Fallback: find any user ID (excluding the nav bar user links) + const m2 = body.match(/group_users\.cgi\?[^"]*nt_user_id=(\d+)/); + if (!m2) throw new Error(`Failed to find created user "${opts.username}" in group ${gid}`); + return m2[1]; + } + return uid; +} + +export async function deleteUser(playwright: any, cookies: string, gid: string | number, uid: string | number): Promise { + // NicTool uses delete=1&obj_list= format for user deletion + await authGet(playwright, `${BASE}/group_users.cgi?nt_group_id=${gid}&delete=1&obj_list=${uid}&csrf_token=${extractCsrf(cookies)}`, cookies); +} + +export async function createNameserver(playwright: any, cookies: string, gid: string | number, + opts: { name: string; address?: string; description?: string; export_format?: string; ttl?: number } +): Promise { + const addr = opts.address || '192.0.2.1'; + const desc = opts.description || 'e2e test ns'; + const fmt = opts.export_format || 'bind'; + const ttl = opts.ttl || 3600; + await authPost(playwright, `${BASE}/group_nameservers.cgi`, cookies, + `nt_group_id=${gid}&new=1&Create=Create&name=${encodeURIComponent(opts.name)}&address=${encodeURIComponent(addr)}&description=${encodeURIComponent(desc)}&export_format=${fmt}&export_interval=120&ttl=${ttl}&csrf_token=${extractCsrf(cookies)}`); + + const { body } = await authGet(playwright, `${BASE}/group_nameservers.cgi?nt_group_id=${gid}&limit=255`, cookies); + const nsid = findIdInBody(body, 'nt_nameserver_id', opts.name); + if (!nsid) { + // Fallback: look for any nameserver ID that isn't one of the default 3 + const allIds = [...body.matchAll(/nt_nameserver_id=(\d+)/g)].map(m => m[1]); + const uniqueIds = [...new Set(allIds)]; + // The new one should be the highest ID + const maxId = uniqueIds.reduce((max, id) => Math.max(max, Number(id)), 0); + if (maxId > 0) return String(maxId); + throw new Error(`Failed to find created nameserver "${opts.name}" in group ${gid}`); + } + return nsid; +} + +export async function deleteNameserver(playwright: any, cookies: string, gid: string | number, nsid: string | number): Promise { + // NicTool uses delete=1&nt_nameserver_id=X format + await authGet(playwright, `${BASE}/group_nameservers.cgi?nt_group_id=${gid}&delete=1&nt_nameserver_id=${nsid}&csrf_token=${extractCsrf(cookies)}`, cookies); +} + +// --------------------------------------------------------------------------- +// Internal Utilities +// --------------------------------------------------------------------------- + +export function extractCsrf(cookies: string): string { + const m = cookies.match(/NicTool_csrf=([^;]+)/); + return m ? m[1] : ''; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/client/t/e2e/logs.spec.ts b/client/t/e2e/logs.spec.ts new file mode 100644 index 00000000..5509bafc --- /dev/null +++ b/client/t/e2e/logs.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, + apiLogin, authGet, authPost, cookieString, + createGroup, deleteGroup, createZone, deleteZone, + createRecord, deleteRecord, uniqueName, extractCsrf, +} from './helpers'; + +test.describe('Logs', () => { + let cookies: string; + let csrfToken: string; + let gid: string; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + cookies = cookieString(sessionCookie, csrfCookie); + csrfToken = csrfCookie; + gid = await createGroup(playwright, cookies, 1, uniqueName('e2e_logs')); + }); + + test.afterAll(async ({ playwright }) => { + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('group_log.cgi renders log page', async ({ playwright }) => { + const { body } = await authGet(playwright, + `${BASE}/group_log.cgi?nt_group_id=${gid}`, cookies); + expect(body).toBeDefined(); + expect(body.toLowerCase()).not.toContain('internal server error'); + // Log page should render without errors + expect(body).toContain('NicTool'); + }); + + test('group_zones_log.cgi shows zone changes', async ({ playwright }) => { + // Create and delete a zone to generate log entries + const zone = `${uniqueName('e2e-log')}.test`; + const zid = await createZone(playwright, cookies, gid, zone); + await deleteZone(playwright, cookies, gid, zid); + + const { body } = await authGet(playwright, + `${BASE}/group_zones_log.cgi?nt_group_id=${gid}`, cookies); + expect(body).toBeDefined(); + expect(body.toLowerCase()).not.toContain('internal server error'); + }); + + test('zone_record_log.cgi shows record changes', async ({ playwright }) => { + const zone = `${uniqueName('e2e-reclog')}.test`; + const zid = await createZone(playwright, cookies, gid, zone); + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'logtest', type: 'A', address: '192.0.2.200' }); + await deleteRecord(playwright, cookies, gid, zid, rrid); + + const { body } = await authGet(playwright, + `${BASE}/zone_record_log.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toBeDefined(); + expect(body.toLowerCase()).not.toContain('internal server error'); + + await deleteZone(playwright, cookies, gid, zid); + }); + + test('log entry appears after creating a zone', async ({ playwright }) => { + const zone = `${uniqueName('e2e-logz')}.test`; + const zid = await createZone(playwright, cookies, gid, zone); + + // Check group zone log for the creation entry + const { body } = await authGet(playwright, + `${BASE}/group_zones_log.cgi?nt_group_id=${gid}`, cookies); + expect(body).toContain(zone); + + await deleteZone(playwright, cookies, gid, zid); + }); + + test('log entry appears after deleting a record', async ({ playwright }) => { + const zone = `${uniqueName('e2e-logr')}.test`; + const zid = await createZone(playwright, cookies, gid, zone); + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'logdel', type: 'A', address: '192.0.2.201' }); + await deleteRecord(playwright, cookies, gid, zid, rrid); + + // Check zone record log for the delete entry + const { body } = await authGet(playwright, + `${BASE}/zone_record_log.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + // Log should mention the deleted record + expect(body).toContain('logdel'); + + await deleteZone(playwright, cookies, gid, zid); + }); + + test('deleted zone has undelete link in log', async ({ playwright }) => { + const zone = `${uniqueName('e2e-undel')}.test`; + const zid = await createZone(playwright, cookies, gid, zone); + await deleteZone(playwright, cookies, gid, zid); + + const { body } = await authGet(playwright, + `${BASE}/group_zones_log.cgi?nt_group_id=${gid}`, cookies); + // After deleting, the log should show the zone and potentially an undelete option + expect(body).toContain(zone); + // Check for undelete or recover link (case-insensitive) + expect(body.toLowerCase()).toMatch(/undelete|recover|restore|deleted/i); + }); +}); diff --git a/client/t/e2e/move.spec.ts b/client/t/e2e/move.spec.ts new file mode 100644 index 00000000..9701763d --- /dev/null +++ b/client/t/e2e/move.spec.ts @@ -0,0 +1,149 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, + apiLogin, authGet, authPost, cookieString, + createGroup, deleteGroup, createZone, deleteZone, + createUser, deleteUser, createNameserver, deleteNameserver, + uniqueName, uniqueNsName, extractCsrf, +} from './helpers'; + +test.describe('Move Operations', () => { + let cookies: string; + let csrfToken: string; + let groupA: string; + let groupB: string; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + cookies = cookieString(sessionCookie, csrfCookie); + csrfToken = csrfCookie; + + groupA = await createGroup(playwright, cookies, 1, uniqueName('e2e_moveA')); + groupB = await createGroup(playwright, cookies, 1, uniqueName('e2e_moveB')); + }); + + test.afterAll(async ({ playwright }) => { + await deleteGroup(playwright, cookies, 1, groupA); + await deleteGroup(playwright, cookies, 1, groupB); + }); + + test('move zone to different group', async ({ playwright }) => { + const zone = `${uniqueName('e2e-move')}.test`; + const zid = await createZone(playwright, cookies, groupA, zone); + + try { + // Move zone: Save=Save, group_list=target, obj_list=zid + const { body } = await authPost(playwright, `${BASE}/move_zones.cgi`, cookies, + `Save=Save&group_list=${groupB}&obj_list=${zid}&csrf_token=${csrfToken}`); + expect(body.toLowerCase()).not.toContain('error_code'); + + const { body: bBody } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${groupB}`, cookies); + expect(bBody).toContain(zone); + } finally { + await deleteZone(playwright, cookies, groupB, zid).catch(() => + deleteZone(playwright, cookies, groupA, zid).catch(() => {})); + } + }); + + test('moved zone absent from source, present in target', async ({ playwright }) => { + const zone = `${uniqueName('e2e-move2')}.test`; + const zid = await createZone(playwright, cookies, groupA, zone); + + try { + await authPost(playwright, `${BASE}/move_zones.cgi`, cookies, + `Save=Save&group_list=${groupB}&obj_list=${zid}&csrf_token=${csrfToken}`); + + const { body: aBody } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${groupA}`, cookies); + expect(aBody).not.toContain(zone); + + const { body: bBody } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${groupB}`, cookies); + expect(bBody).toContain(zone); + } finally { + await deleteZone(playwright, cookies, groupB, zid).catch(() => + deleteZone(playwright, cookies, groupA, zid).catch(() => {})); + } + }); + + test('move user to different group', async ({ playwright }) => { + const username = uniqueName('e2emoveuser'); + const uid = await createUser(playwright, cookies, groupA, { username }); + + try { + const { body } = await authPost(playwright, `${BASE}/move_users.cgi`, cookies, + `Save=Save&group_list=${groupB}&obj_list=${uid}&csrf_token=${csrfToken}`); + expect(body.toLowerCase()).not.toContain('error_code'); + + const { body: bBody } = await authGet(playwright, + `${BASE}/group_users.cgi?nt_group_id=${groupB}`, cookies); + expect(bBody).toContain(username); + } finally { + await deleteUser(playwright, cookies, groupB, uid).catch(() => + deleteUser(playwright, cookies, groupA, uid).catch(() => {})); + } + }); + + test('moved user absent from source, present in target', async ({ playwright }) => { + const username = uniqueName('e2emoveuser2'); + const uid = await createUser(playwright, cookies, groupA, { username }); + + try { + await authPost(playwright, `${BASE}/move_users.cgi`, cookies, + `Save=Save&group_list=${groupB}&obj_list=${uid}&csrf_token=${csrfToken}`); + + const { body: aBody } = await authGet(playwright, + `${BASE}/group_users.cgi?nt_group_id=${groupA}`, cookies); + expect(aBody).not.toContain(username); + + const { body: bBody } = await authGet(playwright, + `${BASE}/group_users.cgi?nt_group_id=${groupB}`, cookies); + expect(bBody).toContain(username); + } finally { + await deleteUser(playwright, cookies, groupB, uid).catch(() => + deleteUser(playwright, cookies, groupA, uid).catch(() => {})); + } + }); + + test('move nameserver to different group', async ({ playwright }) => { + const nsName = `${uniqueNsName('ns-move')}.e2e.test.`; + const nsid = await createNameserver(playwright, cookies, groupA, + { name: nsName, address: '192.0.2.70' }); + + try { + const { body } = await authPost(playwright, `${BASE}/move_nameservers.cgi`, cookies, + `Save=Save&group_list=${groupB}&obj_list=${nsid}&csrf_token=${csrfToken}`); + expect(body.toLowerCase()).not.toContain('error_code'); + + const { body: bBody } = await authGet(playwright, + `${BASE}/group_nameservers.cgi?nt_group_id=${groupB}`, cookies); + expect(bBody).toContain(nsName); + } finally { + await deleteNameserver(playwright, cookies, groupB, nsid).catch(() => + deleteNameserver(playwright, cookies, groupA, nsid).catch(() => {})); + } + }); + + test('moved nameserver absent from source, present in target', async ({ playwright }) => { + const nsName = `${uniqueNsName('ns-move2')}.e2e.test.`; + const nsid = await createNameserver(playwright, cookies, groupA, + { name: nsName, address: '192.0.2.71' }); + + try { + await authPost(playwright, `${BASE}/move_nameservers.cgi`, cookies, + `Save=Save&group_list=${groupB}&obj_list=${nsid}&csrf_token=${csrfToken}`); + + const { body: aBody } = await authGet(playwright, + `${BASE}/group_nameservers.cgi?nt_group_id=${groupA}`, cookies); + expect(aBody).not.toContain(nsName); + + const { body: bBody } = await authGet(playwright, + `${BASE}/group_nameservers.cgi?nt_group_id=${groupB}`, cookies); + expect(bBody).toContain(nsName); + } finally { + await deleteNameserver(playwright, cookies, groupB, nsid).catch(() => + deleteNameserver(playwright, cookies, groupA, nsid).catch(() => {})); + } + }); +}); diff --git a/client/t/e2e/nameservers.spec.ts b/client/t/e2e/nameservers.spec.ts new file mode 100644 index 00000000..3151d983 --- /dev/null +++ b/client/t/e2e/nameservers.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, + apiLogin, authGet, authPost, cookieString, + createGroup, deleteGroup, createNameserver, deleteNameserver, + uniqueName, uniqueNsName, extractCsrf, browserLogin, +} from './helpers'; + +test.describe('Nameservers', () => { + let cookies: string; + let csrfToken: string; + let gid: string; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + cookies = cookieString(sessionCookie, csrfCookie); + csrfToken = csrfCookie; + gid = await createGroup(playwright, cookies, 1, uniqueName('e2e_ns')); + }); + + test.afterAll(async ({ playwright }) => { + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('create nameserver with BIND export format', async ({ playwright }) => { + const name = `${uniqueNsName('ns1')}.e2e.test.`; + const nsid = await createNameserver(playwright, cookies, gid, + { name, address: '192.0.2.53', export_format: 'bind' }); + expect(Number(nsid)).toBeGreaterThan(0); + await deleteNameserver(playwright, cookies, gid, nsid); + }); + + test('nameserver appears in list', async ({ playwright }) => { + const name = `${uniqueNsName('ns2')}.e2e.test.`; + const nsid = await createNameserver(playwright, cookies, gid, + { name, address: '192.0.2.54' }); + + const { body } = await authGet(playwright, + `${BASE}/group_nameservers.cgi?nt_group_id=${gid}`, cookies); + expect(body).toContain(name); + + await deleteNameserver(playwright, cookies, gid, nsid); + }); + + test('edit nameserver description', async ({ playwright }) => { + const name = `${uniqueNsName('ns3')}.e2e.test.`; + const nsid = await createNameserver(playwright, cookies, gid, + { name, address: '192.0.2.55', description: 'original desc' }); + + await authPost(playwright, `${BASE}/group_nameservers.cgi`, cookies, + `nt_group_id=${gid}&nt_nameserver_id=${nsid}&edit=1&Save=Save&name=${encodeURIComponent(name)}&address=192.0.2.55&description=${encodeURIComponent('updated desc')}&export_format=bind&export_interval=120&ttl=3600&csrf_token=${csrfToken}`); + + const { body } = await authGet(playwright, + `${BASE}/group_nameservers.cgi?nt_group_id=${gid}&nt_nameserver_id=${nsid}&edit=1`, cookies); + expect(body).toContain('updated desc'); + + await deleteNameserver(playwright, cookies, gid, nsid); + }); + + test('edit nameserver address', async ({ playwright }) => { + const name = `${uniqueNsName('ns4')}.e2e.test.`; + const nsid = await createNameserver(playwright, cookies, gid, + { name, address: '192.0.2.56' }); + + await authPost(playwright, `${BASE}/group_nameservers.cgi`, cookies, + `nt_group_id=${gid}&nt_nameserver_id=${nsid}&edit=1&Save=Save&name=${encodeURIComponent(name)}&address=192.0.2.57&description=e2e+test+ns&export_format=bind&export_interval=120&ttl=3600&csrf_token=${csrfToken}`); + + const { body } = await authGet(playwright, + `${BASE}/group_nameservers.cgi?nt_group_id=${gid}&nt_nameserver_id=${nsid}&edit=1`, cookies); + expect(body).toContain('192.0.2.57'); + + await deleteNameserver(playwright, cookies, gid, nsid); + }); + + test('delete nameserver', async ({ playwright }) => { + const name = `${uniqueNsName('ns5')}.e2e.test.`; + const nsid = await createNameserver(playwright, cookies, gid, + { name, address: '192.0.2.58' }); + + await deleteNameserver(playwright, cookies, gid, nsid); + + const { body } = await authGet(playwright, + `${BASE}/group_nameservers.cgi?nt_group_id=${gid}`, cookies); + expect(body).not.toContain(name); + }); + + test('nameserver with IPv6 address', async ({ playwright }) => { + const name = `${uniqueNsName('ns6')}.e2e.test.`; + const nsid = await createNameserver(playwright, cookies, gid, + { name, address: '2001:db8::53' }); + expect(Number(nsid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, + `${BASE}/group_nameservers.cgi?nt_group_id=${gid}&nt_nameserver_id=${nsid}&edit=1`, cookies); + expect(body).toContain('2001:db8::53'); + + await deleteNameserver(playwright, cookies, gid, nsid); + }); + + test('export format selector changes form fields', async ({ page }) => { + await browserLogin(page); + + const bodyFrame = page.frames().find(f => f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + const nsLink = bodyFrame!.locator('a:has-text("Nameservers")'); + if (await nsLink.count() > 0) { + await nsLink.first().click(); + await page.waitForTimeout(1500); + } + + const nsFrame = page.frames().find(f => f.url().includes('group_nameservers.cgi')); + if (nsFrame) { + const newNsLink = nsFrame.locator('a:has-text("New Nameserver")'); + if (await newNsLink.count() > 0) { + await newNsLink.first().click(); + await page.waitForTimeout(1500); + } + + const formFrame = page.frames().find(f => f.url().includes('group_nameservers.cgi')); + if (formFrame) { + const formatSelect = formFrame.locator('select[name="export_format"]'); + if (await formatSelect.count() > 0) { + const optionCount = await formatSelect.locator('option').count(); + expect(optionCount).toBeGreaterThan(0); + } + } + } + }); +}); diff --git a/client/t/e2e/navigation.spec.ts b/client/t/e2e/navigation.spec.ts new file mode 100644 index 00000000..7e02c016 --- /dev/null +++ b/client/t/e2e/navigation.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; +import { BASE, browserLogin, getNavFrame, getBodyFrame } from './helpers'; + +test.describe('Navigation', () => { + test('login produces frameset with nav and body frames', async ({ page }) => { + await browserLogin(page); + + const frames = page.frames(); + expect(frames.length).toBeGreaterThan(1); + + const navFrame = frames.find(f => f.url().includes('nav.cgi')); + const bodyFrame = frames.find(f => f.url().includes('group.cgi')); + expect(navFrame).toBeTruthy(); + expect(bodyFrame).toBeTruthy(); + }); + + test('nav frame shows group tree with section links', async ({ page }) => { + await browserLogin(page); + + const navFrame = page.frames().find(f => f.url().includes('nav.cgi')); + expect(navFrame).toBeTruthy(); + + const navContent = await navFrame!.content(); + // Nav should have the group tree and various section links + expect(navContent).toContain('NicTool'); + }); + + test('clicking Zones in nav loads group_zones.cgi in body', async ({ page }) => { + await browserLogin(page); + + const bodyFrame = page.frames().find(f => f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + // Click Zones tab in body frame + const zonesLink = bodyFrame!.locator('a:has-text("Zones")'); + if (await zonesLink.count() > 0) { + await zonesLink.first().click(); + await page.waitForTimeout(2000); + + // After clicking, body frame should now show group_zones.cgi + const updatedBodyFrame = page.frames().find(f => f.url().includes('group_zones.cgi')); + expect(updatedBodyFrame).toBeTruthy(); + } + }); + + test('clicking Users in nav loads group_users.cgi in body', async ({ page }) => { + await browserLogin(page); + + const bodyFrame = page.frames().find(f => f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + const usersLink = bodyFrame!.locator('a:has-text("Users")'); + if (await usersLink.count() > 0) { + await usersLink.first().click(); + await page.waitForTimeout(2000); + + const updatedBodyFrame = page.frames().find(f => f.url().includes('group_users.cgi')); + expect(updatedBodyFrame).toBeTruthy(); + } + }); + + test('clicking Nameservers in nav loads group_nameservers.cgi in body', async ({ page }) => { + await browserLogin(page); + + const bodyFrame = page.frames().find(f => f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + const nsLink = bodyFrame!.locator('a:has-text("Nameservers")'); + if (await nsLink.count() > 0) { + await nsLink.first().click(); + await page.waitForTimeout(2000); + + const updatedBodyFrame = page.frames().find(f => f.url().includes('group_nameservers.cgi')); + expect(updatedBodyFrame).toBeTruthy(); + } + }); + + test('clicking Log in nav loads group_log.cgi in body', async ({ page }) => { + await browserLogin(page); + + const bodyFrame = page.frames().find(f => f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + // The Log link is in the nav bar within the body frame + const logLink = bodyFrame!.locator('a[href*="group_log.cgi"]'); + if (await logLink.count() > 0) { + await logLink.first().click(); + await page.waitForTimeout(2000); + + // The body frame should now be group_log.cgi + const updatedBodyFrame = page.frames().find(f => f.url().includes('group_log.cgi')); + expect(updatedBodyFrame).toBeTruthy(); + } + }); + + test('nav refresh link reloads nav frame', async ({ page }) => { + await browserLogin(page); + + const navFrame = page.frames().find(f => f.url().includes('nav.cgi')); + expect(navFrame).toBeTruthy(); + + // Look for a refresh/reload link in the nav frame + const refreshLink = navFrame!.locator('a:has-text("refresh")'); + if (await refreshLink.count() > 0) { + await refreshLink.first().click(); + await page.waitForTimeout(1500); + + // Nav frame should still be present after refresh + const newNavFrame = page.frames().find(f => f.url().includes('nav.cgi')); + expect(newNavFrame).toBeTruthy(); + } else { + // If there's no explicit refresh link, verify nav frame is functional + const navContent = await navFrame!.content(); + expect(navContent.length).toBeGreaterThan(0); + } + }); +}); diff --git a/client/t/e2e/package-lock.json b/client/t/e2e/package-lock.json new file mode 100644 index 00000000..e585afd3 --- /dev/null +++ b/client/t/e2e/package-lock.json @@ -0,0 +1,72 @@ +{ + "name": "nictool-e2e-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nictool-e2e-tests", + "devDependencies": { + "@playwright/test": "^1.50.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/client/t/e2e/package.json b/client/t/e2e/package.json new file mode 100644 index 00000000..d94e9844 --- /dev/null +++ b/client/t/e2e/package.json @@ -0,0 +1,10 @@ +{ + "name": "nictool-e2e-tests", + "private": true, + "scripts": { + "test": "npx playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.50.0" + } +} diff --git a/client/t/e2e/permissions.spec.ts b/client/t/e2e/permissions.spec.ts new file mode 100644 index 00000000..322a4f60 --- /dev/null +++ b/client/t/e2e/permissions.spec.ts @@ -0,0 +1,174 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, GROUP_DEFAULTS, + apiLogin, authGet, authPost, cookieString, + createGroup, deleteGroup, createZone, deleteZone, + createRecord, deleteRecord, createUser, deleteUser, + uniqueName, extractCsrf, +} from './helpers'; + +test.describe('Permissions', () => { + let rootCookies: string; + let rootCsrf: string; + let gid: string; + let groupName: string; + let zid: string; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + rootCookies = cookieString(sessionCookie, csrfCookie); + rootCsrf = csrfCookie; + groupName = uniqueName('e2e_perms'); + gid = await createGroup(playwright, rootCookies, 1, groupName); + zid = await createZone(playwright, rootCookies, gid, `${uniqueName('e2e')}.test`); + }); + + test.afterAll(async ({ playwright }) => { + await deleteZone(playwright, rootCookies, gid, zid); + await deleteGroup(playwright, rootCookies, 1, gid); + }); + + async function createRestrictedUser(playwright: any, overrides: Record) { + const username = uniqueName('e2erestuser'); + const password = 'restricted123!'; + + // Build permission string with overrides + let perms = GROUP_DEFAULTS; + for (const [key, val] of Object.entries(overrides)) { + perms = perms.replace(new RegExp(`${key}=\\d`), `${key}=${val}`); + } + + // Create a restricted sub-group + const restrictedGroupName = uniqueName('e2e_restricted'); + await authPost(playwright, `${BASE}/group.cgi`, rootCookies, + `nt_group_id=${gid}&new=1&Create=Create&name=${restrictedGroupName}&${perms}&csrf_token=${rootCsrf}`); + + const { body: groupList } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=${gid}`, rootCookies); + const gm = groupList.match(new RegExp(`nt_group_id=(\\d+)[^>]*>\\s*${restrictedGroupName}`)); + if (!gm) { + const gm2 = groupList.match(new RegExp(`nt_group_id=(\\d+)">${restrictedGroupName}`)); + if (!gm2) throw new Error(`Failed to find restricted group ${restrictedGroupName}`); + var restrictedGid = gm2[1]; + } else { + var restrictedGid = gm[1]; + } + + // Create user in the restricted group + const uid = await createUser(playwright, rootCookies, restrictedGid, { username, password }); + + // Login as restricted user + const loginResult = await apiLogin(playwright, `${username}@${restrictedGroupName}`, password); + const userCookies = cookieString(loginResult.sessionCookie, loginResult.csrfCookie); + + return { userCookies, userCsrf: loginResult.csrfCookie, restrictedGid, uid, username }; + } + + async function cleanupRestrictedUser(playwright: any, restrictedGid: string, uid: string) { + await deleteUser(playwright, rootCookies, restrictedGid, uid); + await deleteGroup(playwright, rootCookies, gid, restrictedGid); + } + + test('user without zone_create cannot create zone', async ({ playwright }) => { + const { userCookies, userCsrf, restrictedGid, uid } = await createRestrictedUser(playwright, { zone_create: '0' }); + + try { + const { body } = await authPost(playwright, `${BASE}/group_zones.cgi`, userCookies, + `nt_group_id=${restrictedGid}&new=1&Create=Create&zone=noperm.test&mailaddr=admin.noperm.test&description=test&ttl=3600&refresh=16384&retry=2048&expire=1048576&minimum=2560&csrf_token=${userCsrf}`); + expect(body.toLowerCase()).toMatch(/error|permission|denied|not allowed|access/i); + } finally { + await cleanupRestrictedUser(playwright, restrictedGid, uid); + } + }); + + test('user without zone_write cannot edit zone', async ({ playwright }) => { + const { userCookies, userCsrf, restrictedGid, uid } = await createRestrictedUser(playwright, { zone_write: '0' }); + + try { + // Try to edit the zone created in beforeAll (in parent group) + const { body } = await authPost(playwright, `${BASE}/group_zones.cgi`, userCookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&edit=1&Save=Save&zone=hacked.test&mailaddr=admin.hacked.test&description=hacked&ttl=3600&refresh=16384&retry=2048&expire=1048576&minimum=2560&csrf_token=${userCsrf}`); + expect(body.toLowerCase()).toMatch(/error|permission|denied|not allowed|access/i); + } finally { + await cleanupRestrictedUser(playwright, restrictedGid, uid); + } + }); + + test('user without zone_delete cannot delete zone', async ({ playwright }) => { + const { userCookies, userCsrf, restrictedGid, uid } = await createRestrictedUser(playwright, { zone_delete: '0' }); + + try { + const { body } = await authPost(playwright, `${BASE}/group_zones.cgi`, userCookies, + `nt_group_id=${gid}&delete=1&zone_list=${zid}&csrf_token=${userCsrf}`); + expect(body.toLowerCase()).toMatch(/error|permission|denied|not allowed|access/i); + } finally { + await cleanupRestrictedUser(playwright, restrictedGid, uid); + } + }); + + test('user without zonerecord_create cannot create record', async ({ playwright }) => { + const { userCookies, userCsrf, restrictedGid, uid } = await createRestrictedUser(playwright, { zonerecord_create: '0' }); + + try { + const { body } = await authPost(playwright, `${BASE}/zone.cgi`, userCookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&new_record=1&Create=Create&name=noperm&type=A&address=192.0.2.99&ttl=3600&csrf_token=${userCsrf}`); + expect(body.toLowerCase()).toMatch(/error|permission|denied|not allowed|access/i); + } finally { + await cleanupRestrictedUser(playwright, restrictedGid, uid); + } + }); + + test('user without zonerecord_delete cannot delete record', async ({ playwright }) => { + // Create a record as root to try to delete + const rrid = await createRecord(playwright, rootCookies, gid, zid, + { name: 'perm-del-test', type: 'A', address: '192.0.2.98' }); + + const { userCookies, userCsrf, restrictedGid, uid } = await createRestrictedUser(playwright, { zonerecord_delete: '0' }); + + try { + const { body } = await authPost(playwright, `${BASE}/zone.cgi`, userCookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&nt_zone_record_id=${rrid}&delete_record=${rrid}&csrf_token=${userCsrf}`); + expect(body.toLowerCase()).toMatch(/error|permission|denied|not allowed|access/i); + } finally { + await cleanupRestrictedUser(playwright, restrictedGid, uid); + await deleteRecord(playwright, rootCookies, gid, zid, rrid); + } + }); + + test('user without group_create cannot create sub-group', async ({ playwright }) => { + const { userCookies, userCsrf, restrictedGid, uid } = await createRestrictedUser(playwright, { group_create: '0' }); + + try { + const { body } = await authPost(playwright, `${BASE}/group.cgi`, userCookies, + `nt_group_id=${restrictedGid}&new=1&Create=Create&name=noperm_group&${GROUP_DEFAULTS}&csrf_token=${userCsrf}`); + expect(body.toLowerCase()).toMatch(/error|permission|denied|not allowed|access/i); + } finally { + await cleanupRestrictedUser(playwright, restrictedGid, uid); + } + }); + + test('user without user_create cannot create user', async ({ playwright }) => { + const { userCookies, userCsrf, restrictedGid, uid } = await createRestrictedUser(playwright, { user_create: '0' }); + + try { + const { body } = await authPost(playwright, `${BASE}/group_users.cgi`, userCookies, + `nt_group_id=${restrictedGid}&new=1&Create=Create&username=noperm_user&password=test123!&password2=test123!&email=no@test.example&first_name=No&last_name=Perm&csrf_token=${userCsrf}`); + expect(body.toLowerCase()).toMatch(/error|permission|denied|not allowed|access/i); + } finally { + await cleanupRestrictedUser(playwright, restrictedGid, uid); + } + }); + + test('user with self_write can edit own profile', async ({ playwright }) => { + const { userCookies, userCsrf, restrictedGid, uid, username } = await createRestrictedUser(playwright, { self_write: '1' }); + + try { + // User should be able to edit their own profile + const { body } = await authPost(playwright, `${BASE}/group_users.cgi`, userCookies, + `nt_group_id=${restrictedGid}&nt_user_id=${uid}&edit=1&Save=Save&username=${username}&first_name=SelfEdited&last_name=User&email=selfed@test.example&csrf_token=${userCsrf}`); + // Should not show permission error + expect(body.toLowerCase()).not.toMatch(/permission denied|not allowed/i); + } finally { + await cleanupRestrictedUser(playwright, restrictedGid, uid); + } + }); +}); diff --git a/client/t/e2e/playwright.config.ts b/client/t/e2e/playwright.config.ts new file mode 100644 index 00000000..19d0c638 --- /dev/null +++ b/client/t/e2e/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: '*.spec.ts', + timeout: 30_000, + retries: 0, + workers: 1, + use: { + baseURL: process.env.NICTOOL_URL || 'http://localhost:8080', + ignoreHTTPSErrors: true, + }, + reporter: [['list']], +}); diff --git a/client/t/e2e/records.spec.ts b/client/t/e2e/records.spec.ts new file mode 100644 index 00000000..1bdb0e3c --- /dev/null +++ b/client/t/e2e/records.spec.ts @@ -0,0 +1,300 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, + apiLogin, authGet, authPost, cookieString, + createGroup, deleteGroup, createZone, deleteZone, + createRecord, deleteRecord, uniqueName, extractCsrf, + browserLogin, +} from './helpers'; + +test.describe('Records', () => { + let cookies: string; + let csrfToken: string; + let gid: string; + let zid: string; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + cookies = cookieString(sessionCookie, csrfCookie); + csrfToken = csrfCookie; + gid = await createGroup(playwright, cookies, 1, uniqueName('e2e_recs')); + zid = await createZone(playwright, cookies, gid, `${uniqueName('e2e')}.test`); + }); + + test.afterAll(async ({ playwright }) => { + await deleteZone(playwright, cookies, gid, zid); + await deleteGroup(playwright, cookies, 1, gid); + }); + + // --- Basic record types --- + + test('create A record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'a-test', type: 'A', address: '192.0.2.1' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('192.0.2.1'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create AAAA record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'aaaa-test', type: 'AAAA', address: '2001:db8::1' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + // Address may be displayed in expanded or compressed IPv6 form + expect(body).toMatch(/2001:db8:.*1|2001:0db8/i); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create CNAME record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'cname-test', type: 'CNAME', address: 'target.example.com.' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('target.example.com.'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create MX record with weight', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'mx-test', type: 'MX', address: 'mail.example.com.', weight: '10' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('mail.example.com.'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create TXT record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'txt-test', type: 'TXT', address: 'v=spf1 include:example.com ~all' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('v=spf1'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create NS record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'ns-test', type: 'NS', address: 'ns1.example.com.' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('ns1.example.com.'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('edit A record address', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'edit-test', type: 'A', address: '192.0.2.10' }); + + await authPost(playwright, `${BASE}/zone.cgi`, cookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&nt_zone_record_id=${rrid}&edit_record=${rrid}&Save=Save&name=edit-test&type=A&address=192.0.2.20&ttl=3600&csrf_token=${csrfToken}`); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('192.0.2.20'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('edit MX record weight', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'mx-edit', type: 'MX', address: 'mail.example.com.', weight: '10' }); + + await authPost(playwright, `${BASE}/zone.cgi`, cookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&nt_zone_record_id=${rrid}&edit_record=${rrid}&Save=Save&name=mx-edit&type=MX&address=mail.example.com.&weight=20&ttl=3600&csrf_token=${csrfToken}`); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('20'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('delete record via POST', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'del-test', type: 'A', address: '192.0.2.99' }); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).not.toContain('del-test'); + }); + + test('multiple record types coexist in same zone', async ({ playwright }) => { + const r1 = await createRecord(playwright, cookies, gid, zid, + { name: 'multi-a', type: 'A', address: '192.0.2.50' }); + const r2 = await createRecord(playwright, cookies, gid, zid, + { name: 'multi-mx', type: 'MX', address: 'mx.example.com.', weight: '10' }); + const r3 = await createRecord(playwright, cookies, gid, zid, + { name: 'multi-txt', type: 'TXT', address: 'hello world' }); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('192.0.2.50'); + expect(body).toContain('mx.example.com.'); + expect(body).toContain('hello world'); + + await deleteRecord(playwright, cookies, gid, zid, r1); + await deleteRecord(playwright, cookies, gid, zid, r2); + await deleteRecord(playwright, cookies, gid, zid, r3); + }); + + test('create record with custom TTL', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'ttl-test', type: 'A', address: '192.0.2.60', ttl: 86400 }); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('86400'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('PTR record in reverse zone', async ({ playwright }) => { + // Create a reverse zone + const revZone = `2.0.192.in-addr.arpa`; + let revZid: string; + try { + revZid = await createZone(playwright, cookies, gid, revZone); + } catch { + // If the zone already exists or can't be created, skip + return; + } + + try { + const rrid = await createRecord(playwright, cookies, gid, revZid, + { name: '1', type: 'PTR', address: 'host.example.com.' }); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${revZid}`, cookies); + expect(body).toContain('host.example.com.'); + + await deleteRecord(playwright, cookies, gid, revZid, rrid); + } finally { + await deleteZone(playwright, cookies, gid, revZid); + } + }); + + // --- Advanced record types --- + + test('create SRV record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: '_sip._tcp', type: 'SRV', address: 'sip.example.com.', weight: '10', priority: '0', other: '5060' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('sip.example.com.'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create CAA record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'caa-test', type: 'CAA', address: 'letsencrypt.org', weight: '0', other: 'issue' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('letsencrypt.org'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create SPF record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'spf-test', type: 'SPF', address: 'v=spf1 -all' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('v=spf1'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create LOC record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'loc-test', type: 'LOC', address: '51 30 12.748 N 0 7 39.612 W 0.00m' }); + expect(Number(rrid)).toBeGreaterThan(0); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create SSHFP record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'sshfp-test', type: 'SSHFP', address: '123456789abcdef67890123456789abcdef67890', weight: '1', other: '1' }); + expect(Number(rrid)).toBeGreaterThan(0); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create NAPTR record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'naptr-test', type: 'NAPTR', address: '!^.*$!sip:info@example.com!', weight: '100', priority: '10', other: 'u' }); + expect(Number(rrid)).toBeGreaterThan(0); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('create DNAME record', async ({ playwright }) => { + const rrid = await createRecord(playwright, cookies, gid, zid, + { name: 'dname-test', type: 'DNAME', address: 'other.example.com.' }); + expect(Number(rrid)).toBeGreaterThan(0); + + const { body } = await authGet(playwright, `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${zid}`, cookies); + expect(body).toContain('other.example.com.'); + + await deleteRecord(playwright, cookies, gid, zid, rrid); + }); + + test('RR type selector dynamically shows/hides fields', async ({ page }) => { + await browserLogin(page); + + const bodyFrame = page.frames().find(f => f.url().includes('group.cgi')); + expect(bodyFrame).toBeTruthy(); + + // Navigate to zones + const zonesLink = bodyFrame!.locator('a:has-text("Zones")'); + if (await zonesLink.count() > 0) { + await zonesLink.first().click(); + await page.waitForTimeout(1500); + } + + // Find a zone to click into - or just verify the type selector exists on a zone page + const zonesFrame = page.frames().find(f => f.url().includes('group_zones.cgi')); + if (zonesFrame) { + const zoneLink = zonesFrame.locator('a[href*="nt_zone_id="]').first(); + if (await zoneLink.count() > 0) { + await zoneLink.click(); + await page.waitForTimeout(1500); + + const zoneFrame = page.frames().find(f => f.url().includes('zone.cgi')); + if (zoneFrame) { + // Look for the "New Record" link/area + const newRecLink = zoneFrame.locator('a:has-text("New Resource Record")'); + if (await newRecLink.count() > 0) { + await newRecLink.first().click(); + await page.waitForTimeout(1500); + } + + const typeSelect = zoneFrame.locator('select[name="type"]'); + if (await typeSelect.count() > 0) { + // Select MX - weight field should be visible + await typeSelect.selectOption('MX'); + await page.waitForTimeout(300); + + // The weight field row should be present + const weightInput = zoneFrame.locator('input[name="weight"]'); + expect(await weightInput.count()).toBeGreaterThan(0); + } + } + } + } + }); +}); diff --git a/client/t/e2e/search-sort.spec.ts b/client/t/e2e/search-sort.spec.ts new file mode 100644 index 00000000..b919726d --- /dev/null +++ b/client/t/e2e/search-sort.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, + apiLogin, authGet, cookieString, + createGroup, deleteGroup, createZone, deleteZone, + createRecord, deleteRecord, uniqueName, +} from './helpers'; + +test.describe('Search and Sort', () => { + let cookies: string; + let gid: string; + const zones: { name: string; zid: string }[] = []; + + test.beforeAll(async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + cookies = cookieString(sessionCookie, csrfCookie); + + gid = await createGroup(playwright, cookies, 1, uniqueName('e2e_search')); + + // Create multiple zones for search/sort testing + for (let i = 1; i <= 5; i++) { + const name = `search-${i}-${Date.now()}.test`; + const zid = await createZone(playwright, cookies, gid, name); + zones.push({ name, zid }); + } + }); + + test.afterAll(async ({ playwright }) => { + for (const z of zones) { + await deleteZone(playwright, cookies, gid, z.zid); + } + await deleteGroup(playwright, cookies, 1, gid); + }); + + test('zone search by exact name', async ({ playwright }) => { + const targetZone = zones[0]; + // Quick search form uses search_value parameter + const { body } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${gid}&Quick+search=Search&search_value=${encodeURIComponent(targetZone.name)}`, + cookies); + expect(body).toContain(targetZone.name); + }); + + test('zone search with include_subgroups', async ({ playwright }) => { + const { body } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${gid}&Quick+search=Search&search_value=search&include_subgroups=1`, + cookies); + expect(body).toContain('search-'); + }); + + test('record search by type', async ({ playwright }) => { + const targetZone = zones[0]; + const r1 = await createRecord(playwright, cookies, gid, targetZone.zid, + { name: 'srch-a', type: 'A', address: '192.0.2.100' }); + const r2 = await createRecord(playwright, cookies, gid, targetZone.zid, + { name: 'srch-mx', type: 'MX', address: 'mx.example.com.', weight: '10' }); + + try { + // View zone records - both should be visible + const { body } = await authGet(playwright, + `${BASE}/zone.cgi?nt_group_id=${gid}&nt_zone_id=${targetZone.zid}`, + cookies); + expect(body).toContain('192.0.2.100'); + expect(body).toContain('srch-a'); + } finally { + await deleteRecord(playwright, cookies, gid, targetZone.zid, r1); + await deleteRecord(playwright, cookies, gid, targetZone.zid, r2); + } + }); + + test('pagination: page 1 with limit returns subset', async ({ playwright }) => { + const { body } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${gid}&start=0&limit=2`, + cookies); + // Should show zone content + expect(body).toContain('search-'); + // The page should have navigation or limit indicators + expect(body).toBeDefined(); + }); + + test('pagination: page 2 returns different results', async ({ playwright }) => { + const { body: page1 } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${gid}&start=0&limit=2`, + cookies); + const { body: page2 } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${gid}&start=2&limit=2`, + cookies); + + // Both pages should have content and not be identical + expect(page1.length).toBeGreaterThan(0); + expect(page2.length).toBeGreaterThan(0); + }); + + test('sort zones descending', async ({ playwright }) => { + const { body } = await authGet(playwright, + `${BASE}/group_zones.cgi?nt_group_id=${gid}&sort1=zone&sortmod1=Descending`, + cookies); + expect(body).toContain('search-'); + // Should not crash with sort params + expect(body.toLowerCase()).not.toContain('internal server error'); + }); +}); diff --git a/client/t/e2e/security.spec.ts b/client/t/e2e/security.spec.ts new file mode 100644 index 00000000..aef46d9c --- /dev/null +++ b/client/t/e2e/security.spec.ts @@ -0,0 +1,340 @@ +import { test, expect } from '@playwright/test'; +import { + BASE, USERNAME, PASSWORD, GROUP_DEFAULTS, + freshCtx, getLoginCsrf, apiLogin, authGet, authPost, + expectSecurityHeaders, browserLogin, collectViolations, +} from './helpers'; + +// --------------------------------------------------------------------------- +// T1: Security Headers +// --------------------------------------------------------------------------- +test.describe('T1: Security Headers', () => { + test('login page has all security headers', async ({ playwright }) => { + const ctx = await freshCtx(playwright); + const res = await ctx.get(`${BASE}/index.cgi`); + expect(res.status()).toBe(200); + expectSecurityHeaders(res.headers()); + await ctx.dispose(); + }); + + test('authenticated page has all security headers', async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + const cookies = `NicTool=${sessionCookie}; NicTool_csrf=${csrfCookie}`; + const { res } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + expect(res.status()).toBe(200); + expectSecurityHeaders(res.headers()); + }); +}); + +// --------------------------------------------------------------------------- +// T2: Cookie Flags +// --------------------------------------------------------------------------- +test.describe('T2: Cookie Flags', () => { + test('NicTool session cookie has HttpOnly, Secure, SameSite=Strict', async ({ playwright }) => { + const { setCookieHeaders } = await apiLogin(playwright); + const sessionHeader = setCookieHeaders.find(h => /^NicTool=[^;]/.test(h.value) && !h.value.startsWith('NicTool_csrf')); + expect(sessionHeader).toBeTruthy(); + const val = sessionHeader!.value.toLowerCase(); + expect(val).toContain('httponly'); + expect(val).toContain('samesite=strict'); + expect(val).toContain('secure'); + }); + + test('NicTool_csrf cookie: no HttpOnly, SameSite=Strict, 40-char hex', async ({ playwright }) => { + const { setCookieHeaders } = await apiLogin(playwright); + const csrfHeader = setCookieHeaders.find(h => h.value.startsWith('NicTool_csrf=')); + expect(csrfHeader).toBeTruthy(); + const val = csrfHeader!.value; + expect(val.toLowerCase()).not.toContain('httponly'); + expect(val.toLowerCase()).toContain('samesite=strict'); + const m = val.match(/NicTool_csrf=([^;]+)/); + expect(m).toBeTruthy(); + expect(m![1]).toMatch(/^[0-9a-f]{40}$/); + }); +}); + +// --------------------------------------------------------------------------- +// T3: Login Page Link URLs +// --------------------------------------------------------------------------- +test.describe('T3: Login Page Links', () => { + test('License and Source links have correct URLs', async ({ page }) => { + await page.goto(`${BASE}/index.cgi`); + const licenseLink = page.locator('a:has-text("License")'); + await expect(licenseLink).toHaveAttribute('href', 'https://www.gnu.org/licenses/agpl-3.0.html'); + const sourceLink = page.locator('a:has-text("Source")'); + await expect(sourceLink).toHaveAttribute('href', 'https://github.com/NicTool/NicTool'); + }); +}); + +// --------------------------------------------------------------------------- +// T4: CSRF Token Present in Login Form +// --------------------------------------------------------------------------- +test.describe('T4: CSRF in Login Form', () => { + test('login form has csrf_token hidden field matching cookie', async ({ playwright }) => { + const ctx = await freshCtx(playwright); + const { csrfToken, csrfCookie } = await getLoginCsrf(ctx); + await ctx.dispose(); + + expect(csrfToken).toBeTruthy(); + expect(csrfToken.length).toBe(40); + expect(csrfToken).toMatch(/^[0-9a-f]{40}$/); + expect(csrfCookie).toBeTruthy(); + expect(csrfCookie).toBe(csrfToken); + }); +}); + +// --------------------------------------------------------------------------- +// T5: CSRF Blocks Forged Login +// --------------------------------------------------------------------------- +test.describe('T5: CSRF Blocks Forged Login', () => { + test('login rejected with wrong csrf_token', async ({ playwright }) => { + const ctx = await freshCtx(playwright); + const { csrfCookie } = await getLoginCsrf(ctx); + const res = await ctx.post(`${BASE}/index.cgi`, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': `NicTool_csrf=${csrfCookie}` }, + data: `username=${USERNAME}&password=${PASSWORD}&login=Enter&csrf_token=0000000000000000000000000000000000000000`, + }); + const body = await res.text(); + expect(body).toContain('Session expired'); + expect(body.toLowerCase()).not.toContain(' { + const ctx = await freshCtx(playwright); + const { csrfCookie } = await getLoginCsrf(ctx); + const res = await ctx.post(`${BASE}/index.cgi`, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': `NicTool_csrf=${csrfCookie}` }, + data: `username=${USERNAME}&password=${PASSWORD}&login=Enter`, + }); + const body = await res.text(); + expect(body).toContain('Session expired'); + expect(body.toLowerCase()).not.toContain(' { + test('group create rejected without csrf_token', async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + const cookies = `NicTool=${sessionCookie}; NicTool_csrf=${csrfCookie}`; + const { body } = await authPost(playwright, `${BASE}/group.cgi`, cookies, + `nt_group_id=1&new=1&Create=Create&name=csrf_test_group&${GROUP_DEFAULTS}`); + expect(body).toContain('CSRF validation failed'); + }); +}); + +// --------------------------------------------------------------------------- +// T7: Normal CRUD Operations (Regression) +// --------------------------------------------------------------------------- +test.describe('T7: CRUD Regression', () => { + test('full lifecycle: create group, zone, record; edit record; delete all', async ({ playwright }) => { + const { sessionCookie, csrfCookie } = await apiLogin(playwright); + const cookies = `NicTool=${sessionCookie}; NicTool_csrf=${csrfCookie}`; + + // --- Create a sub-group --- + const groupName = `e2e_test_${Date.now()}`; + await authPost(playwright, `${BASE}/group.cgi`, cookies, + `nt_group_id=1&new=1&Create=Create&name=${groupName}&${GROUP_DEFAULTS}&csrf_token=${csrfCookie}`); + + // Fetch the group list to find the new group ID + let { body } = await authGet(playwright, `${BASE}/group.cgi?nt_group_id=1`, cookies); + const groupIdMatch = body.match(new RegExp(`nt_group_id=(\\d+)">${groupName}`)); + expect(groupIdMatch).toBeTruthy(); + const gid = groupIdMatch![1]; + + // --- Create a zone --- + const zoneName = `e2e-${Date.now()}.test`; + await authPost(playwright, `${BASE}/group_zones.cgi`, cookies, + `nt_group_id=${gid}&new=1&Create=Create&zone=${zoneName}&mailaddr=admin.${zoneName}&description=e2e&ttl=3600&refresh=16384&retry=2048&expire=1048576&minimum=2560&csrf_token=${csrfCookie}`); + + // Fetch zone list to find zone ID + ({ body } = await authGet(playwright, `${BASE}/group_zones.cgi?nt_group_id=${gid}`, cookies)); + const zoneIdMatch = body.match(/nt_zone_id=(\d+)/); + expect(zoneIdMatch).toBeTruthy(); + const zid = zoneIdMatch![1]; + + // --- Create an A record --- + ({ body } = await authPost(playwright, `${BASE}/zone.cgi`, cookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&new_record=1&Create=Create&name=testhost&type=A&address=192.0.2.1&ttl=3600&csrf_token=${csrfCookie}`)); + expect(body).not.toContain('CSRF validation failed'); + expect(body).toContain('testhost'); + + // Find record ID + const recordIdMatch = body.match(/nt_zone_record_id=(\d+)/); + expect(recordIdMatch).toBeTruthy(); + const rrid = recordIdMatch![1]; + + // --- Edit the record --- + ({ body } = await authPost(playwright, `${BASE}/zone.cgi`, cookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&nt_zone_record_id=${rrid}&edit_record=${rrid}&Save=Save&name=testhost&type=A&address=192.0.2.2&ttl=3600&csrf_token=${csrfCookie}`)); + expect(body).not.toContain('CSRF validation failed'); + expect(body).toContain('192.0.2.2'); + + // --- Delete the record (POST) --- + ({ body } = await authPost(playwright, `${BASE}/zone.cgi`, cookies, + `nt_group_id=${gid}&nt_zone_id=${zid}&nt_zone_record_id=${rrid}&delete_record=${rrid}&csrf_token=${csrfCookie}`)); + expect(body).not.toContain('CSRF validation failed'); + + // --- Delete the zone --- + await authPost(playwright, `${BASE}/group_zones.cgi`, cookies, + `nt_group_id=${gid}&delete=1&zone_list=${zid}&csrf_token=${csrfCookie}`); + + // --- Delete the group (include csrf_token in query string) --- + ({ body } = await authGet(playwright, + `${BASE}/group.cgi?nt_group_id=1&delete=${gid}&csrf_token=${csrfCookie}`, cookies)); + expect(body).not.toContain(groupName); + }); +}); + +// --------------------------------------------------------------------------- +// T8: XSS in Login Error Message +// --------------------------------------------------------------------------- +test.describe('T8: XSS Protection', () => { + test('script tag in message param is escaped or stripped', async ({ playwright }) => { + const ctx = await freshCtx(playwright); + const res = await ctx.get(`${BASE}/index.cgi?message=`); + const body = await res.text(); + expect(body).not.toContain(''); + expect(body).not.toMatch(/