Viewing File: /usr/local/cpanel/whostmgr/docroot/cgi/hostaccess.cgi

#!/usr/local/cpanel/3rdparty/bin/perl

#                                      Copyright 2026 WebPros International, LLC
#                                                           All rights reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.

use strict;
use warnings;

use Cpanel::Encoder::Tiny   ();
use Cpanel::Form            ();
use Cpanel::OS              ();
use Whostmgr::ACLS          ();
use Whostmgr::HTMLInterface ();

# How many empty rows the rule form offers for new entries. The template
# renders them after the existing rules and numbers them past the end of the
# list, and savehostaccesslist() has to accept those numbers back, so both
# sides read it from here rather than each hardcoding ten.
use constant BLANK_RULE_ROWS => 10;

# The highest action index the form can submit for a rule. Its JavaScript adds
# one input at a time, numbered by how many actions are already filled in, and
# a hosts.allow line carries a handful of directives -- this is far above any
# real value while still keeping the array bounded.
use constant MAX_ACTION_INDEX => 63;

if ( !check_acls('all') ) {
    html_error( 403, "Forbidden" );
    require Cpanel::Exit;
    Cpanel::Exit::exit_with_stdout_closed_first();
}

my %FORM = Cpanel::Form::parseform();
if ( $FORM{'add_nftables_rule'} ) {
    eval {
        require Cpanel::XTables;
        my $obj = Cpanel::XTables->new(
            'chain' => 'cPanel-HostAccessControl',
        );
        if ( !$obj->table_exists( 'name' => 'filter', 'family' => 'inet' ) ) {
            my $create_line = [ [qw'add table inet filter'] ];
            $obj->exec_checked_calls($create_line);
        }
        $obj->init_chain()          if !$obj->chain_exists();
        $obj->attach_chain('INPUT') if !$obj->is_chain_attached('INPUT');
        $obj->add_rule(
            'port'     => $FORM{'port'},
            'ip'       => $FORM{'ip'},
            'protocol' => $FORM{'protocol'},
            'action'   => $FORM{'action'},
        );
    };
    if ($@) {
        html_error( 500, $@ );
        require Cpanel::Exit;
        Cpanel::Exit::exit_with_stdout_closed_first();
    }

    $FORM{'no_cache'} = 1;
}
elsif ( $FORM{'delete_nftables_rule'} ) {
    eval {
        require Cpanel::XTables;
        Cpanel::XTables->new(
            'chain' => 'cPanel-HostAccessControl',
        )->delete_rule(
            'handle' => $FORM{'delete_nftables_rule'},
        );
    };

    # Without this the delete path dies at top level, before any header is
    # emitted, and the client gets a response with no status and no content
    # type at all.
    if ($@) {
        html_error( 500, $@ );
        require Cpanel::Exit;
        Cpanel::Exit::exit_with_stdout_closed_first();
    }

    $FORM{'no_cache'} = 1;
}
if ( $FORM{'fetch_nftables_rules'} ) {
    my $rules = [];
    eval {
        require Cpanel::JSON;
        require Cpanel::XTables;
        my $obj = Cpanel::XTables->new( 'chain' => 'cPanel-HostAccessControl' );
        $obj->clear_ruleset_cache();
        $rules = $obj->get_rules;
    };
    if ($@) {
        push @$rules, { 'error' => $@ };
        print "Status: 500\r\nContent-type: application/json\r\n\r\n";
    }
    else {
        print "Status: 200\r\nContent-type: application/json\r\n\r\n";
    }
    print Cpanel::JSON::pretty_dump($rules);
    require Cpanel::Exit;
    Cpanel::Exit::exit_with_stdout_closed_first();
}

print "Content-type: text/html\r\n\r\n";
do_main();

sub do_main {
    require Cpanel::Template;
    my $saved;
    if ( exists $FORM{'save_accesslist'} ) {
        $saved = savehostaccesslist();
    }

    if ( Cpanel::OS::supports_hostaccess() ) {

        require Cpanel::HostAccessLib;
        my $hostaccesslib = Cpanel::HostAccessLib->new;

        Cpanel::Template::process_template(
            'whostmgr',
            {
                'template_file' => 'host_access/hostaccess.tmpl',
                'data'          => {
                    'action'     => q{},
                    'saved'      => $saved,
                    'services'   => $hostaccesslib->fetch_services(),
                    'actions'    => $hostaccesslib->fetch_actions(),
                    'wildcards'  => $hostaccesslib->fetch_wildcards(),
                    'rules'      => $hostaccesslib->{'DB'},
                    'blank_rows' => BLANK_RULE_ROWS,
                    'emptyrule'  => {
                        'daemon_list' => [],
                        'client_list' => [],
                        'action_list' => [],
                        'comment'     => '',
                        'type'        => 'empty_action',
                    },
                },
            },
        );
    }
    else {

        # Host Access Controls not supported on CentOS 8+, so we
        # have a different implementation based on nftables.
        Cpanel::Template::process_template(
            'whostmgr',
            {
                'template_file' => 'host_access/nftables_access.tmpl',
                'data'          => {

                    # The template drops this into a JavaScript object literal,
                    # and Template Toolkit does not escape by default, so a
                    # request parameter passed through verbatim would execute.
                    # It is only ever a flag, so reduce it to one.
                    'no_cache' => $FORM{'no_cache'} ? 1 : 0,
                },
            },
        );
    }
    return;
}

sub savehostaccesslist {
    require Cpanel::HostAccessLib;
    my $hostaccesslib = Cpanel::HostAccessLib->new;
    for ( my $i = 0; $i <= $#{ $hostaccesslib->{'DB'} }; $i++ ) {
        ${ $hostaccesslib->{'DB'} }[$i]->{'linenum'} = ( $i + 1 );
        foreach my $list ( 'daemon_list', 'action_list', 'client_list' ) {
            if ( defined ${ $hostaccesslib->{'DB'} }[$i]->{$list} ) {
                ${ $hostaccesslib->{'DB'} }[$i]->{$list} = [];
            }
        }
    }

    foreach my $key ( keys %FORM ) {
        my $evalue = $FORM{$key};
        my ( $slinenum, $element ) = split( /-/, $key, 2 );

        next if ( !defined $slinenum || $slinenum !~ /^\d+$/ || !defined $element );

        # The form renders every existing rule and then BLANK_RULE_ROWS empty
        # ones, numbered past the end of the list. Writing into those slots is
        # how a rule gets added -- commit() skips any entry that still has an
        # empty daemon or client list -- so the bound has to leave room for
        # them. It exists at all because a number outside that window is not
        # something the form can produce: zero decrements to -1 and silently
        # rewrites the last entry, and a large one autovivifies every slot up
        # to it before the loops below serialize the lot back out.
        next if $slinenum < 1 || $slinenum > scalar( @{ $hostaccesslib->{'DB'} } ) + BLANK_RULE_ROWS;

        my $linenum = int $slinenum;
        $linenum--;

        my ( $keyname, $keynum ) = split( /_/, $element, 2 );

        if ( $keyname eq 'daemon' ) {
            ${ $hostaccesslib->{'DB'} }[$linenum]->{'daemon_list'} = Cpanel::HostAccessLib::daemon_parse($evalue);
        }
        elsif ( $keyname eq 'client' ) {
            ${ $hostaccesslib->{'DB'} }[$linenum]->{'client_list'} = Cpanel::HostAccessLib::client_parse($evalue);
        }
        elsif ( $keyname eq 'action' ) {

            # Same hazard as the line number, and the same reasoning: the form
            # allocates these indices densely from zero, one input at a time,
            # so anything outside that range is not a value it can submit.
            next if !defined $keynum || $keynum =~ tr/0-9//c || $keynum > MAX_ACTION_INDEX;

            my $newval = Cpanel::HostAccessLib::ptrim($evalue);
            next if ( $newval eq '' );
            ${ ${ $hostaccesslib->{'DB'} }[$linenum]->{'action_list'} }[$keynum] = $newval;
        }
        elsif ( $keyname eq 'comment' ) {
            ${ $hostaccesslib->{'DB'} }[$linenum]->{'comment'} = $evalue;
        }
    }
    foreach my $line ( split( /:/, $FORM{'eventlist'} ) ) {
        my ( $linenum, $offset ) = split( /,/, $line );
        foreach ( my $i = 0; $i <= $#{ $hostaccesslib->{'DB'} }; $i++ ) {
            if ( ${ $hostaccesslib->{'DB'} }[$i]->{'linenum'} == $linenum ) {
                my $newpt = ( $i + $offset );
                my @nl    = splice( @{ $hostaccesslib->{'DB'} }, $i, 1 );
                splice( @{ $hostaccesslib->{'DB'} }, $newpt, 0, @nl );
                last;
            }
        }
    }
    for ( my $i = 0; $i <= $#{ $hostaccesslib->{'DB'} }; $i++ ) {
        foreach my $list ( 'daemon_list', 'action_list', 'client_list' ) {
            if ( !defined ${ $hostaccesslib->{'DB'} }[$i]->{$list} ) {
                ${ $hostaccesslib->{'DB'} }[$i]->{$list} = [];
            }
        }
        if ( !defined ${ $hostaccesslib->{'DB'} }[$i]->{'type'} ) {
            ${ $hostaccesslib->{'DB'} }[$i]->{'type'} = 'access_list';
        }
    }
    $hostaccesslib->reserialize();
    $hostaccesslib->commit();

    return 1;
}

sub check_acls {
    my @acls = @_;
    Whostmgr::ACLS::init_acls();
    return scalar( grep { Whostmgr::ACLS::checkacl($_) } @acls ) == scalar(@acls);
}

sub html_error {
    my ( $code, $msg ) = @_;

    # The message can carry request data verbatim: a failed 'nft' call reports
    # the whole command line, which includes the submitted port, ip, protocol
    # and action. It must be HTML-encoded, otherwise those parameters are a
    # reflected XSS vector.
    my $safe_msg = Cpanel::Encoder::Tiny::safe_html_encode_str( _error_text($msg) );

    print "Status: $code\r\nContent-type: text/html\r\n\r\n";
    Whostmgr::HTMLInterface::defheader();
    print "<h1>$safe_msg</h1>";
    Whostmgr::HTMLInterface::sendfooter();
    return;
}

sub _error_text {
    my ($err) = @_;

    # Take the message, never the stringified object: Cpanel::Exception's
    # overload appends a stack trace listing the arguments each frame was called
    # with, so rendering "$err" would put the submitted values back on the page
    # even when the message itself is clean. The ID and class belong in the
    # logs, not in front of an administrator.
    return $err->to_string_no_id() if ref $err && eval { $err->can('to_string_no_id') };

    # Drop the source location Perl appends to a die() that has no trailing
    # newline. A module path and line number tell an administrator nothing and
    # describe our internals to anyone else.
    my $text = defined $err ? "$err" : q{};
    $text =~ s{\s*at \S+ line [0-9]+\.?\s*\z}{};

    return $text;
}
Back to Directory