package OVH::Bastion; # vim: set filetype=perl ts=4 sw=4 sts=4 et: use common::sense; use Time::Piece; # $t->strftime # Check if a system user belongs to a specific system group sub is_user_in_group { my %params = @_; my $group = $params{'group'}; my $user = $params{'user'} || OVH::Bastion::get_user_from_env()->value; my $cache = $params{'cache'}; # allow cache use of sys_getgr_name() # mandatory keys if (!$user || !$group) { return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'user' or 'group'"); } my $fnret = OVH::Bastion::sys_getgr_name(name => $group, cache => $cache); $fnret or return $fnret; if (grep { $user eq $_ } @{$fnret->value->{'members'} || []}) { return R('OK', value => {group => $group, account => $user}); } else { return R('KO_NOT_IN_GROUP', msg => "Account $user doesn't belong to the group $group"); } } # does this system group exist? if it happens to be mapped to a bastion group, # also return the corresponding "shortGroup" (with the "key" prefix removed) sub is_group_existing { my %params = @_; my $group = $params{'group'}; my $cache = $params{'cache'}; # allow cache use of sys_getgr_name() my $user_friendly_error = $params{'user_friendly_error'}; if (!$group) { return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'group'"); } my $fnret = OVH::Bastion::sys_getgr_name(name => $group, cache => $cache); if ($fnret) { my (undef, $shortGroup) = $group =~ m{^(key)?(.+)}; return R( 'OK', value => { group => $group, shortGroup => $shortGroup, gid => $fnret->value->{'gid'}, keyhome => "/home/keykeeper/$group", members => $fnret->value->{'members'}, } ); } # build a user-compatible error message if asked to, as it can make its way through osh_exit() # pragma:hookignore if ($user_friendly_error) { $group =~ s/^key//; return R('KO_GROUP_NOT_FOUND', msg => "The bastion group '$group' doesn't exist.\n" . "You may use groupList --all to see all existing groups."); } return R('KO_GROUP_NOT_FOUND', msg => "Group '$group' doesn't exist"); } # validate uid/gid sub is_valid_uid { my %params = @_; my $uid = $params{'uid'}; my $type = $params{'type'}; # Basic input validation if ($uid !~ m/^\d+$/) { return R('ERR_INVALID_PARAMETER', msg => "Parameter 'uid' should be numeric"); } if ($type ne 'user' and $type ne 'group') { return R('ERR_INVALID_PARAMETER', msg => "Parameter 'type' is invalid"); } # Input validation against configuration my $fnret = OVH::Bastion::load_configuration(); $fnret or return $fnret; my ($accountUidMin, $accountUidMax, $ttyrecGroupIdOffset) = @{$fnret->value}{qw{ accountUidMin accountUidMax ttyrecGroupIdOffset }}; if (not $accountUidMin or not $accountUidMax or not $ttyrecGroupIdOffset) { return R('ERR_CANNOT_LOAD_CONFIGURATION'); } my ($low, $high) = ($accountUidMin, $accountUidMax); if ($type eq 'group') { $high += $ttyrecGroupIdOffset; } if ($uid < $low or $uid > $high) { return R('KO_BAD_RANGE', msg => "Parameter 'uid' should be between $low and $high"); } # untaint if ($uid =~ m/^(\d+)$/) { return R('OK', value => $1); } warn_syslog("Got an invalid uid ('$uid')"); return R('ERR_INVALID_UID', msg => "Got an invalid uid ('$uid')"); } sub get_next_available_uid { my %params = @_; # if true, also check for the availability of the corresponding GID: my $available_gid = $params{'available_gid'}; # if true, also check for the availability of the corresponding GID + the ttyrec offset: my $available_gid_ttyrec = $params{'available_gid_ttyrec'}; my $higher = OVH::Bastion::config('accountUidMax')->value(); my $lower = OVH::Bastion::config('accountUidMin')->value(); my $next = $higher; my $found = 0; while (1) { # find the first available UID, starting from the upper ID allowed and decrementing while ($next >= $lower) { last if not scalar(getpwuid($next)); $next--; } # did we get out of the loop because we found a candidate, or because we're out of bounds? last if $next < $lower; # if $available_gid, also check if the corresponding GID is available # if $available_gid_ttyrec, also check if the corresponding GID + the ttyrec offset is available if ( (!$available_gid || !scalar(getgrgid($next))) && (!$available_gid_ttyrec || !scalar(getgrgid($next + OVH::Bastion::config('ttyrecGroupIdOffset')->value))) ) { $found = 1; last; } # if we're here, at least one of the $available_gid* check failed, so continue looking $next--; } return R('OK', value => $next) if $found; return R('ERR_UID_COLLISION', msg => "No available UID in the allowed range"); } sub is_bastion_account_valid_and_existing { my %params = @_; my $fnret = OVH::Bastion::is_account_valid(%params); $fnret or return $fnret; my %values = %{$fnret->value()}; my ($account, $realm, $sysaccount, $remoteaccount) = @values{qw{ account realm sysaccount remoteaccount}}; $fnret = OVH::Bastion::is_account_existing(account => $sysaccount, checkBastionShell => 1, cache => $params{'cache'}); $fnret or return $fnret; $fnret->value->{'account'} = $account; $fnret->value->{'sysaccount'} = $sysaccount; $fnret->value->{'realm'} = $realm; $fnret->value->{'remoteaccount'} = $remoteaccount; return $fnret; } # check if account name is valid, i.e. non-weird chars and non reserved parts sub is_account_valid { my %params = @_; my $account = $params{'account'}; my $accountType = $params{'accountType'} || 'normal'; # normal (local account or $realm/$remoteself formatted account) | group (must start with key*) | realm (must start with realm_*) my $localOnly = $params{'localOnly'}; # for accountType == normal, disallow realm-formatted accounts ($realm/$remoteself) my $realmOnly = $params{'realmOnly'}; # for accountType == normal, allow only realm-formatted accounts ($realm/$remoteself) if (!$account) { return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account'"); } my $whatis = ($accountType eq 'realm' ? "Realm" : "Account"); if ($localOnly && $account =~ m{/}) { return R('KO_REALM_FORBIDDEN', msg => "$whatis name must not contain any '/'"); } elsif ($realmOnly && $account !~ m{/}) { return R('KO_LOCAL_FORBIDDEN', msg => "$whatis name must contain a '/'"); } elsif ($account =~ m/^[-.]/) { return R('KO_FORBIDDEN_PREFIX', msg => "$whatis name must not start with a '-' nor a '.'"); } elsif ($account =~ m/-(?:tty|aclkeeper|gatekeeper|owner)$/i) { return R('KO_FORBIDDEN_SUFFIX', msg => "$whatis name contains an unauthorized suffix"); } elsif ($account =~ m/^key/i && $accountType ne 'group') { return R('KO_FORBIDDEN_PREFIX', msg => "$whatis name contains an unauthorized key prefix"); } elsif ($account !~ m/^key/i && $accountType eq 'group') { return R('KO_BAD_PREFIX', msg => "$whatis should start with the group prefix"); } elsif ($account =~ m/^realm_/ && $accountType ne 'realm') { return R('KO_FORBIDDEN_PREFIX', msg => "$whatis name contains an unauthorized realm prefix"); } elsif ($account !~ m/^realm_/ && $accountType eq 'realm') { return R('KO_BAD_PREFIX', msg => "$whatis should start with the realm prefix"); } elsif (grep { $account eq $_ } qw{ root proxyhttp keykeeper passkeeper logkeeper realm realm_realm }) { return R('KO_FORBIDDEN_NAME', msg => "$whatis name is reserved"); } elsif ($account =~ m{^([a-zA-Z0-9-]+)/([a-zA-Z0-9._-]+)$} && $accountType eq 'normal') { # 32 is the max Linux user length if (length("realm_$1") > 32) { return R('KO_TOO_LONG', msg => "$whatis name is too long, length(realm_$1) > 32"); } elsif (length($1) < 2) { return R('KO_TOO_SMALL', msg => "$whatis name is too long, length($1) < 2"); } # 28 because all accounts have a corresponding "-tty" group, and 32 - length(-tty) == 28 elsif (length($2) > 28) { return R('KO_TOO_LONG', msg => "Remote account name is too long, length($2) > 28"); } elsif (length($2) < 2) { return R('KO_TOO_SMALL', msg => "Remote account name is too short, length($2) < 2"); } return R('OK', value => {sysaccount => "realm_$1", realm => $1, remoteaccount => $2, account => "$1/$2"}); # untainted } elsif ($account =~ m/^([a-zA-Z0-9._-]+)$/) { if (length($1) < 2) { return R('KO_TOO_SMALL', msg => "$whatis name is too small, length($1) < 2"); } # 28 because all accounts have a corresponding "-tty" group, and 32 - length(-tty) == 28 elsif (length($1) > 28) { return R('KO_TOO_LONG', msg => "$whatis name is too long, length($1) > 28"); } return R('OK', value => {sysaccount => $1, realm => undef, remoteaccount => undef, account => $1}); # untainted } else { return R('KO_FORBIDDEN_CHARS', msg => "$whatis name contains forbidden characters $account"); } return R('ERR_IMPOSSIBLE_CASE'); } sub is_account_existing { my %params = @_; my $account = $params{'account'}; my $checkBastionShell = $params{'checkBastionShell'}; # check if this account is a bastion user my $cache = $params{'cache'}; # allow cache use sys_getpw_name() if (!$account) { return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account'"); } my %entry; if (OVH::Bastion::is_mocking()) { my @fields = OVH::Bastion::mock_get_account_entry(account => $account); %entry = ( name => $fields[0], passwd => $fields[1], uid => $fields[2], gid => $fields[3], gcos => $fields[4], dir => $fields[5], shell => $fields[6], ); } else { my $fnret = OVH::Bastion::sys_getpw_name(name => $account, cache => $cache); if ($fnret) { %entry = %{$fnret->value}; } } if (%entry) { my ($newname) = $entry{'name'} =~ m{([a-zA-Z0-9._-]+)}; return R('ERR_SECURITY_VIOLATION', msg => "Forbidden characters in account name") if ($newname ne $entry{'name'}); $entry{'name'} = $newname; # untaint if ($checkBastionShell && $entry{'shell'} ne $OVH::Bastion::BASEPATH . "/bin/shell/osh.pl") { return R('KO_NOT_FOUND', msg => "Account '$account' doesn't exist"); # msg is the same as below, voluntarily } my ($newdir) = $entry{'dir'} =~ m{([/a-zA-Z0-9._-]+)}; # untaint return R('ERR_SECURITY_VIOLATION', msg => "Forbidden characters in account home directory") if ($newdir ne $entry{'dir'}); $entry{'dir'} = $newdir; # untaint return R('OK', value => {uid => $entry{'uid'}, gid => $entry{'gid'}, dir => $entry{'dir'}, account => $entry{'name'}}); } return R('KO_NOT_FOUND', msg => "Account '$account' doesn't exist"); } # all ACL modifications (on groups, on accounts, including group-guests) are handled here sub access_modify { my %params = @_; my $action = $params{'action'}; # add or del my $user = $params{'user'}; # if undef, means a user-wildcard access my $ip = $params{'ip'}; # can be a single ip or prefix my $port = $params{'port'}; # if undef, means a port-wildcard access my $ttl = $params{'ttl'}; my $comment = $params{'comment'}; my $way = $params{'way'}; # group, groupguest, personal my $group = $params{'group'}; # only for way=group or way=groupguest my $account = $params{'account'}; # only for way=personal my $forceKey = $params{'forceKey'}; my $forcePassword = $params{'forcePassword'}; my $dryrun = $params{'dryrun'}; # don't do anything, just check params and prereqs my $sudo = $params{'sudo'}; # passed as-is to subs we use # deny accesses wider than these prefixes my %widestVxPrefix = ( 4 => $params{'widestV4Prefix'}, 6 => $params{'widestV6Prefix'}, ); my $fnret; foreach my $mandatoryParam (qw/action ip way/) { if (!$params{$mandatoryParam}) { return R('ERR_MISSING_PARAMETER', msg => "Missing parameter '$mandatoryParam'"); } } # if undef, default to sudo==1 $sudo //= 1; # due to how plugins work, sometimes user and port are just '', make them undef in those cases undef $user if (defined $user && $user eq ''); undef $port if (defined $port && $port eq ''); # check way if ($way eq 'personal') { return R('ERR_INVALID_PARAMETER', msg => "Group parameter specified with way=personal") if defined $group; return R('ERR_MISSING_PARAMETER', msg => "Account parameter mandatory with way=personal") if not defined $account; } elsif ($way eq 'group') { return R('ERR_MISSING_PARAMETER', msg => "Group parameter mandatory with way=group") if not defined $group; return R('ERR_INVALID_PARAMETER', msg => "Account parameter specified with way=group") if defined $account; } elsif ($way eq 'groupguest') { if (not defined $account or not defined $group) { return R('ERR_MISSING_PARAMETER', msg => "Account or group parameter missing with way=groupguest"); } } else { return R('ERR_INVALID_PARAMETER', msg => "Parameter 'way' must be either personal, group or groupguest"); } if ($action ne 'add' and $action ne 'del') { return R('ERR_INVALID_PARAMETER', msg => "Action should be either 'del' or 'add'"); } # check ip $fnret = OVH::Bastion::is_valid_ip(ip => $ip, allowPrefixes => 1); return $fnret unless $fnret; $ip = $fnret->value->{'ip'}; if ($fnret->value->{'type'} eq 'prefix') { my $ipVersion = $fnret->value->{'version'}; if (defined $widestVxPrefix{$ipVersion} && $fnret->value->{'prefixlen'} < $widestVxPrefix{$ipVersion}) { return R( 'ERR_INVALID_PARAMETER', msg => sprintf( "Specified prefix (/%d) is too wide, maximum allowed for IPv%d is /%d by this bastion policy", $fnret->value->{'prefixlen'}, $ipVersion, $widestVxPrefix{$ipVersion} ), ); } } # check port if (defined $port) { $fnret = OVH::Bastion::is_valid_port(port => $port); return $fnret unless $fnret; $port = $fnret->value; } # check remote user if (defined $user) { $fnret = OVH::Bastion::is_valid_remote_user(user => $user); return $fnret unless $fnret; $user = $fnret->value; } # check account my ($remoteaccount, $sysaccount); if (defined $account) { # accountType==normal : account must NOT be a realm_* account (but can be a realm/jdoe account) $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => 'normal'); $fnret or return $fnret; $sysaccount = $fnret->value->{'sysaccount'}; $account = $fnret->value->{'account'}; $remoteaccount = $fnret->value->{'remoteaccount'}; } # check group my $shortGroup; if (defined $group) { $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); $fnret or return $fnret; $group = $fnret->value->{'group'}; # untainted $shortGroup = $fnret->value->{'shortGroup'}; # untainted } # check key fingerprint if ($forceKey) { $fnret = OVH::Bastion::is_valid_fingerprint(fingerprint => $forceKey); $fnret or return $fnret; $forceKey = $fnret->value->{'fingerprint'}; } # check password hash if ($forcePassword) { $fnret = OVH::Bastion::is_valid_hash(hash => $forcePassword); $fnret or return $fnret; $forcePassword = $fnret->value->{'hash'}; } if ($ttl) { if ($ttl =~ /^(\d+)$/) { $ttl = $1; } else { return R('ERR_INVALID_PARAMETER', msg => "The TTL must be numeric"); } } # check if the caller has the right to make the change they're asking # ... 1. either $> is allowkeeper and $ENV{'SUDO_USER'} is the requesting account # ... 2. or $> is $grouptomodify and $ENV{'SUDO_USER'} is the requesting account my ($running_as) = (getpwuid($>))[0] =~ /([0-9a-zA-Z_.-]+)/; my $requester; if ($sudo) { ($requester) = $ENV{'SUDO_USER'} =~ /([0-9a-zA-Z_.-]+)/; } else { $requester = $running_as; } # requester can never be a realm_* account, because it's shared and should not be able to add access to anything return R('ERR_SECURITY_VIOLATION', msg => "Requester can't be a realm user") if $requester =~ /^realm_/; my @one_should_succeed; my $expected_running_as = 'allowkeeper'; if ($way eq 'personal') { if ($requester eq $account) { push @one_should_succeed, OVH::Bastion::is_user_in_group( user => $requester, group => 'osh-self' . ucfirst($action) . 'PersonalAccess', sudo => $sudo, ); } # this is not a else here: somebody who has the account* right doesn't need the self* right push @one_should_succeed, OVH::Bastion::is_user_in_group( user => $requester, group => 'osh-account' . ucfirst($action) . 'PersonalAccess', sudo => $sudo ); } elsif ($way eq 'group') { $expected_running_as = $group; push @one_should_succeed, OVH::Bastion::is_group_aclkeeper(account => $requester, group => $shortGroup, superowner => 1, sudo => $sudo); } elsif ($way eq 'groupguest') { push @one_should_succeed, OVH::Bastion::is_group_gatekeeper( account => $requester, group => $shortGroup, superowner => 1, sudo => $sudo ); } if ($running_as ne $expected_running_as && !$dryrun) { warn_syslog("Security violation: current running user ($running_as) unexpected (wanted $expected_running_as)"); return R('ERR_SECURITY_VIOLATION', msg => "Current running user unexpected"); } if (grep({ $_ } @one_should_succeed) == 0 && $requester ne 'root' && !$dryrun) { warn_syslog( "Security violation: requesting user '$requester' doesn't have the right to do that (way=$way, group=" . ($shortGroup ? '' : $shortGroup) . ")"); return R('ERR_SECURITY_VIOLATION', msg => "You're not allowed to do that"); } # end of dryrun return R('OK', msg => "Would have added the access but we've been called with dryrun") if $dryrun; # now, check if the access we're being asked to change is already in place or not osh_debug( "for action $action of $user\@$ip:$port of way $way with account=$account and group=$group, checking if already granted" ); $fnret = OVH::Bastion::is_access_way_granted( user => $user, ip => $ip, port => $port, way => $way, group => $shortGroup, account => $account, exactMatch => 1, # we're checking if the exact right we're asked to modify exists or not ); osh_debug("... result is $fnret"); if ($action eq 'add' and $fnret) { return R('OK_NO_CHANGE', msg => "The requested access to add was already granted"); } elsif ($action eq 'del' and not $fnret) { return R('OK_NO_CHANGE', msg => "The requested access to delete was not found, no change made"); } # ok, now do the change, first define this sub my $_access_modify_file = sub { my %sub_params = @_; my $file = $sub_params{'file'}; # we don't check our params or the rights because our caller already did, guaranteed by the scoping of this sub # check if we can access the file if (!(-e $file)) { # it doesn't exist yet, create it OVH::Bastion::touch_file($file, oct(644)); if (!(-e $file)) { return R('ERR_CANNOT_CREATE_FILE', msg => "File '$file' is missing and couldn't be created"); } } # can we write to it ? if (!(-w $file)) { return R('ERR_CANNOT_OPEN_FILE', msg => "File '$file' cannot be written to"); } # build the line we're either adding or looking for (to delete it) my $entry = $ip; $entry = $user . "@" . $entry if defined $user; $entry = $entry . ":" . $port if defined $port; my $machine = $entry; my $t = localtime(time); my $fmt = "%Y-%m-%d %H:%M:%S"; my $date = $t->strftime($fmt); my $entryComment = "# $action by $requester on $date"; # if we're adding it, append other parameters as comments if ($action eq 'add') { $entry .= " $entryComment"; if ($forceKey) { # hash is case-sensitive only for new SHA256 format $forceKey = lc($forceKey) if ($forceKey !~ /^sha256:/i); $entry .= " # FORCEKEY=" . $forceKey; } if ($forcePassword) { $entry .= " # FORCEPASSWORD=" . $forcePassword; } if ($ttl) { $entry .= " # EXPIRY=" . (time() + $ttl); } if ($comment) { $comment =~ s{[#<>\\"']}{_}g; $entry .= " # COMMENT=<" . $comment . ">"; } } # to be extra sure, remove any \n in $entry, which is impossible because we vetted all the params, # but if somehow we failed, we'll be sure it doesn't permit to add multiple rights at once $entry =~ s/[\r\n]*//gm; # now, do the change my $returnmsg; if ($action eq 'add') { osh_debug("going to add entry '$entry'"); if (open(my $fh_file, '>>', $file)) { print $fh_file $entry . "\n"; close($fh_file); } else { return R('ERR_CANNOT_OPEN_FILE', msg => "Error opening $file: $!"); } my $ttlmsg = $ttl ? (' (expires in ' . OVH::Bastion::duration2human(seconds => $ttl)->value->{'human'} . ')') : ''; $returnmsg = "Access to $machine successfully added$ttlmsg"; } elsif ($action eq 'del') { if (open(my $fh_file, '<', $file)) { my $newFile; my $found = 0; while (my $line = <$fh_file>) { if ($line =~ m{^\Q$entry\E(\s|$)}) { chomp $line; $line = "# $line # $comment\n"; $found++; } $newFile .= $line; } close($fh_file); if ($found) { # now rewrite if (open(my $fh_file, '>', $file)) { print $fh_file $newFile; close($fh_file); $returnmsg = "Access to $machine successfully removed"; } else { return R('ERR_CANNOT_OPEN_FILE', msg => "Unable to write open $file"); } } else { return R('OK_NO_CHANGE', msg => "Entry $entry was not present in file $file"); } } } OVH::Bastion::syslogFormatted( severity => 'info', type => 'acl', fields => [ ['action', $params{'action'}], ['type', $params{'way'}], ['group', $shortGroup], ['account', $params{'account'}], ['user', $params{'user'}], ['ip', $params{'ip'}], ['port', $params{'port'}], ['ttl', $params{'ttl'}], ['force_key', $params{'forceKey'}], ['force_password', $params{'forcePassword'}], ['comment', $params{'comment'}], ] ); return R('OK', msg => $returnmsg) if $returnmsg; return R('ERR_INTERNAL'); }; # end of sub definition # then call the sub we just defined delete $params{'file'}; my $ret; my $prefix = $remoteaccount ? "allowed_$remoteaccount" : "allowed"; if ($way eq 'personal') { $ret = $_access_modify_file->(%params, file => "/home/allowkeeper/$sysaccount/$prefix.private"); } elsif ($way eq 'group') { $ret = $_access_modify_file->(%params, file => "/home/$group/allowed.ip"); } elsif ($way eq 'groupguest') { $ret = $_access_modify_file->(%params, file => "/home/allowkeeper/$sysaccount/$prefix.partial.$shortGroup"); } osh_debug("_access_modify_file() said $ret"); return $ret if defined $ret; return R('ERR_INTERNAL'); # unreachable } # Check that a group is valid or not (syntax) sub is_valid_group { my %params = @_; my $group = $params{'group'}; my $groupType = $params{'groupType'}; # possible groupTypes: # osh: osh-accountList # tty: login8-tty # key: keymygroup # gatekeeper: keymygroup-gatekeeper # aclkeeper: keymygroup-aclkeeper # owner: keymygroup-owner # regular: no check appart from the length and forbidden prefixes/suffixes if (!$group) { return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'group'"); } # autodetect if my caller prefixed the group name with 'key' or not, and adjust accordingly. # we'll return normalized group and shortGroup values to our caller if ($group !~ /^key/ && defined $groupType && grep { $groupType eq $_ } qw{ key gatekeeper aclkeeper owner }) { $group = "key$group"; } if ($group =~ m/keeper$/i and not grep { $groupType eq $_ } qw{ gatekeeper aclkeeper }) { return R('KO_FORBIDDEN_SUFFIX', msg => 'Forbidden suffix in group name'); } elsif ($group =~ m/owner$/i and $groupType ne 'owner') { return R('KO_FORBIDDEN_SUFFIX', msg => 'Forbidden suffix in group name'); } elsif ($group =~ m/-tty$/i and $groupType ne 'tty') { return R('KO_FORBIDDEN_SUFFIX', msg => 'Forbidden suffix in group name'); } elsif ($group =~ m/^key/i and not grep { $groupType eq $_ } qw{ key gatekeeper owner }) { return R('KO_FORBIDDEN_PREFIX', msg => 'Forbidden prefix in group name'); } elsif ($group =~ m/^[-.]/) { return R('KO_FORBIDDEN_PREFIX', msg => "Group name can't start with a '-' nor a '.'"); } elsif ($group =~ /^(key)?(private|root|user|self|legacy|osh)(-(gatekeeper|aclkeeper|owner))?$/) { return R('KO_FORBIDDEN_NAME', msg => 'Forbidden group name'); } elsif ($group =~ m/^([a-zA-Z0-9._-]+)$/) { $group = $1; # untainted if ($groupType eq 'key' and $group !~ m/^key/) { return R('KO_MISSING_PREFIX', msg => "The group $group should have a prefix (group type $groupType)"); } elsif ($groupType eq 'gatekeeper' and $group !~ m/^key.+-gatekeeper$/) { return R('KO_MISSING_PREFIX', msg => "The group $group should have a prefix (group type $groupType)"); } elsif ($groupType eq 'owner' and $group !~ m/^key.+-owner$/) { return R('KO_MISSING_PREFIX', msg => "The group $group should have a prefix (group type $groupType)"); } elsif ($groupType and $groupType eq 'tty' and $group !~ m/-tty$/) { return R('KO_MISSING_SUFFIX', msg => "The group $group should have a suffix (group type $groupType)"); } my $shortGroup = $group; $shortGroup =~ s/^key|^osh-|-(gatekeeper|aclkeeper|owner|tty)$//g; if (length($group) > 32) { # 32 max for the whole group (system limit) return R('KO_NAME_TOO_LONG', msg => 'Group name is too long (system limit)'); } # 18 max for the short group name, because 32 - length(key) - length(-gatekeeper) == 18 if ((grep { $groupType eq $_ } qw{ key gatekeeper aclkeeper owner }) && (length($shortGroup) > 18)) { return R('KO_NAME_TOO_LONG', msg => "Group name is too long (limit is 18 chars)"); } return R('OK', value => {group => $group, shortGroup => $shortGroup}); } return R('KO_FORBIDDEN_NAME', msg => 'Group name contains invalid characters'); } sub is_valid_group_and_existing { my %params = @_; my $fnret = OVH::Bastion::is_valid_group(%params); $fnret or return $fnret; $params{'group'} = $fnret->value->{'group'}; return OVH::Bastion::is_group_existing(%params, user_friendly_error => 1); } # Add a user to a group sub add_user_to_group { my %params = @_; my $group = $params{'group'}; my $user = $params{'user'}; my $accountType = $params{'accountType'}; my $groupType = $params{'groupType'}; my $fnret; osh_debug('validating user'); $fnret = OVH::Bastion::is_account_valid(account => $user, accountType => $accountType); $fnret or return $fnret; osh_debug('user is ok'); $user = $fnret->value->{'account'} || $fnret->value->{'realm'}; osh_debug('validating group name'); if ($groupType) { $fnret = OVH::Bastion::is_valid_group(group => $group, groupType => $groupType); } else { $fnret = OVH::Bastion::is_valid_group(group => $group); } $fnret or return $fnret; osh_debug('group name is ok'); $group = $fnret->value->{'group'}; $fnret = OVH::Bastion::sys_addmembertogroup(group => $group, user => $user); $fnret or return R('ERR_USERMOD_FAILED', msg => "Error while adding $user to group $group (" . $fnret->msg . ")"); return R('OK'); } # return the list of the bastion groups (i.e. not the system group list) sub get_group_list { my %params = @_; my $cache = $params{'cache'}; # allow cache use of sys_getgr_all() # we loop through all the system groups and only retain those starting # with "key", and not finishing in -owner, -gatekeeper or -aclkeeper. # we also exclude special builtin groups (keykeeper and keyreader) my $fnret = OVH::Bastion::sys_getgr_all(cache => $cache); $fnret or return $fnret; my %groups; foreach my $name (keys %{$fnret->value}) { if ( $name =~ /^key/ && $name !~ /-(?:owner|gatekeeper|aclkeeper)$/ && !grep { $name eq $_ } qw{ keykeeper keyreader }) { my $entry = $fnret->value->{$name}; $name =~ s/^key//; $groups{$name} = {gid => $entry->{'gid'}, members => $entry->{'members'}} if ($name ne ''); } } return R('OK', value => \%groups); } # return the list of bastion accounts (i.e. not the system user list) sub get_account_list { my %params = @_; my $accounts = $params{'accounts'} || []; my $cache = $params{'cache'}; # allow cache use of sys_getpw_all() # note that is_bastion_account_valid_and_existing() passthroughs its # $cache param to sys_getpw_name() too # we loop through all the accounts known to the OS my $fnret = OVH::Bastion::sys_getpw_all(cache => $cache); $fnret or return $fnret; my %users; foreach my $name (keys %{$fnret->value}) { # if $accounts has been specified, only consider those next if (@$accounts && !grep { $name eq $_ } @$accounts); # skip invalid accounts. # if !$cache, then we've filled the cache with sys_getpw_all() just above, # so it's OK to actually use it in all cases next if not OVH::Bastion::is_bastion_account_valid_and_existing(account => $name, cache => 1); my $entry = $fnret->value->{$name}; # add proper accounts, only include a subset of the fields we got $users{$name} = { name => $entry->{'name'}, gid => $entry->{'gid'}, home => $entry->{'dir'}, shell => $entry->{'shell'}, uid => $entry->{'uid'} }; } return R('OK', value => \%users); } sub get_realm_list { my %params = @_; my $realms = $params{'realms'} || []; my $cache = $params{'cache'}; # allow cache use of sys_getent_pw() # note that is_bastion_account_valid_and_existing() passthroughs its # $cache param to sys_getent_pw() too # we loop through all the accounts known to the OS my $fnret = OVH::Bastion::sys_getpw_all(cache => $cache); $fnret or return $fnret; my %users; foreach my $name (keys %{$fnret->value}) { # if $realms has been specified, only consider those next if (@$realms && !grep { $name eq "realm_$_" } @$realms); # skip invalid realms. # if !$cache, then we've filled the cache with sys_getpw_all() just above, # so it's OK to actually use it in all cases next if !OVH::Bastion::is_bastion_account_valid_and_existing( account => $name, accountType => "realm", cache => 1 ); # add proper realms $name =~ s{^realm_}{}; $users{$name} = {name => $name}; } return R('OK', value => \%users); } # check if account is a bastion admin (gives access to adminXyz commands) # hint: an admin is also always a superowner sub is_admin { my %params = @_; my $sudo = $params{'sudo'}; # we're run under sudo my $account = $params{'account'}; my $cache = $params{'cache'}; # allow cache use of sys_getgr_name() through is_user_in_group() if (not $account) { $account = $sudo ? $ENV{'SUDO_USER'} : OVH::Bastion::get_user_from_env()->value; } if (not $account) { return R('ERR_INTERNAL_ERROR'); } if (not $sudo and exists $ENV{'SUDO_USER'}) { # only legit case is if we have osh.pl under sudo because of an admin (adminSudo / ssh-as), check this if (not OVH::Bastion::is_admin(account => $ENV{'SUDO_USER'}, sudo => 1)) { warn_syslog("is_admin(): wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'} . " from account " . $params{'account'}); return R('ERR_SECURITY_VIOLATION', msg => "Wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'}); } } my $adminList = OVH::Bastion::config('adminAccounts')->value(); if (grep { $account eq $_ } @$adminList) { return OVH::Bastion::is_user_in_group(group => "osh-admin", user => $account, cache => $cache); } return R('KO_ACCESS_DENIED'); } # check if account is a superowner # hint: an admin is also always a superowner sub is_super_owner { my %params = @_; my $sudo = $params{'sudo'}; # we're run under sudo my $account = $params{'account'}; my $cache = $params{'cache'}; # allow cache use of sys_getgr_name() through is_user_in_group() if (not $account) { $account = $sudo ? $ENV{'SUDO_USER'} : OVH::Bastion::get_user_from_env()->value; } if (not $account) { return R('ERR_INTERNAL_ERROR'); } if (not $sudo and exists $ENV{'SUDO_USER'}) { # only legit case is if we have osh.pl under sudo because of an admin (adminSudo / ssh-as), check this if (not OVH::Bastion::is_admin(account => $ENV{'SUDO_USER'}, sudo => 1)) { warn_syslog("is_super_owner(): wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'} . " from account " . $params{'account'}); return R('ERR_SECURITY_VIOLATION', msg => "Wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'}); } } my $superownerList = OVH::Bastion::config('superOwnerAccounts')->value(); if (grep { $account eq $_ } @$superownerList) { return OVH::Bastion::is_user_in_group(group => "osh-superowner", user => $account, cache => $cache); } # if admin, then we're good too return OVH::Bastion::is_admin(account => $account, sudo => $sudo, cache => $cache); } # check if account is an auditor sub is_auditor { my %params = @_; my $sudo = $params{'sudo'}; # we're run under sudo my $account = $params{'account'}; my $cache = $params{'cache'}; # allow cache use of sys_getgr_name() through is_user_in_group() if (not $account) { $account = $sudo ? $ENV{'SUDO_USER'} : OVH::Bastion::get_user_from_env()->value; } if (not $account) { return R('ERR_INTERNAL_ERROR'); } if (not $sudo and exists $ENV{'SUDO_USER'}) { # only legit case is if we have osh.pl under sudo because of an admin (adminSudo / ssh-as), check this if (not OVH::Bastion::is_admin(account => $ENV{'SUDO_USER'}, sudo => 1)) { warn_syslog("is_auditor(): wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'} . " from account " . $params{'account'}); return R('ERR_SECURITY_VIOLATION', msg => "Wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'}); } } return OVH::Bastion::is_user_in_group(group => "osh-auditor", user => $account); } # used by funcs below sub _has_group_role { my %params = @_; my $account = $params{'account'}; my $shortGroup = $params{'group'}; my $role = $params{'role'}; # regular or gatekeeper or owner my $superowner = $params{'superowner'}; # allow superowner (will always return yes if so) my $sudo = $params{'sudo'}; # are we run under sudo ? my $cache = $params{'cache'}; # allow cache use of sys_getgr_name() through is_user_in_group() and # is_bastion_account_valid_and_existing() my $fnret; if (not $account) { $account = $sudo ? $ENV{'SUDO_USER'} : OVH::Bastion::get_user_from_env()->value; } if (not $account) { return R('ERR_MISSING_PARAMETER', msg => 'Expected parameter account'); } if (not $sudo and exists $ENV{'SUDO_USER'}) { # only legit case is if we have osh.pl under sudo because of an admin (adminSudo / ssh-as), check this if (not OVH::Bastion::is_admin(account => $ENV{'SUDO_USER'}, sudo => 1)) { warn_syslog("_has_group_role(): wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'} . " from account " . $params{'account'}); return R('ERR_SECURITY_VIOLATION', msg => "Wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'}); } } my $group = "key$shortGroup"; # "regular" means "member or guest", i.e. user is in group key$GROUPNAME if ($role ne 'regular') { $group .= "-$role"; } # for the realm case, we need to test sysaccount and not just account $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, cache => $cache); $fnret or return $fnret; my $sysaccount = $fnret->value->{'sysaccount'}; $fnret = OVH::Bastion::is_user_in_group(user => $sysaccount, group => $group, cache => $cache); osh_debug("is <$sysaccount> in <$group> ? => " . ($fnret ? 'yes' : 'no')); if ($fnret) { $fnret->{'value'} = {account => $account, sysaccount => $sysaccount}; return $fnret; } # if superowner allowed, try it if ($superowner) { if (OVH::Bastion::is_super_owner(account => $sysaccount, sudo => $sudo, cache => $cache)) { osh_debug("is <$sysaccount> in <$group> ? => no but superowner so YES!"); return R('OK', value => {account => $account, sysaccount => $sysaccount, superowner => 1}); } } # not admin or no superowner allowed... return is_user_in_group status but fixup the value if true $fnret->{'value'} = {account => $account, sysaccount => $sysaccount} if $fnret; return $fnret; } sub is_group_aclkeeper { my %params = @_; $params{'role'} = 'aclkeeper'; return _has_group_role(%params); } sub is_group_gatekeeper { my %params = @_; $params{'role'} = 'gatekeeper'; return _has_group_role(%params); } sub is_group_owner { my %params = @_; $params{'role'} = 'owner'; return _has_group_role(%params); } sub _is_group_member_or_guest { my %params = @_; my $shortGroup = $params{'group'}; my $want = $params{'want'}; # guest or member my $cache = $params{'cache'}; # allow cache use of sys_getpw_name() through # is_bastion_account_valid_and_existing() and sys_getgr_name() # through is_valid_group_and_existing() my $fnret = _has_group_role(%params, role => "regular"); $fnret or return $fnret; my $account = $fnret->value()->{'account'}; $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, cache => $cache); $fnret or return $fnret; $account = $fnret->value->{'account'}; my $remoteaccount = $fnret->value->{'remoteaccount'}; my $sysaccount = $fnret->value->{'sysaccount'}; my $group = "key$shortGroup"; $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key", cache => $cache); $fnret or return $fnret; $group = $fnret->value()->{'group'}; $shortGroup = $fnret->value()->{'shortGroup'}; # untainted my $weare = 'guest'; # to be a member (old name: "full member"); one also need to have the symlink my $prefix = $remoteaccount ? "allowed_$remoteaccount" : "allowed"; if (-l "/home/allowkeeper/$sysaccount/$prefix.ip.$shortGroup") { # -l => test that file exists and is a symlink # -r => test that the symlink dest still exists => REMOVED, because we (the caller) might not have the right to read the file if we're not member or guest ourselves $weare = 'member'; } return R('OK') if ($weare eq $want); return R('KO'); } # test if account is strictly a guest (i.e. if a member, then answer is no) sub is_group_guest { my %params = @_; $params{'want'} = 'guest'; return _is_group_member_or_guest(%params); } # test if account is strictly a member (i.e. if a guest, then answer is no) sub is_group_member { my %params = @_; $params{'want'} = 'member'; return _is_group_member_or_guest(%params); } sub get_remote_accounts_from_realm { my %params = @_; my $realm = $params{'realm'}; my $cache = $params{'cache'}; # allow cache use of sys_getpw_name() through is_bastion_account_valid_and_existing() $realm = "realm_$realm" if $realm !~ /^realm_/; my $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $realm, accountType => "realm", cache => $cache); $fnret or return $fnret; my $sysaccount = $fnret->value->{'sysaccount'}; my $allowkeeperdir = "/home/allowkeeper/$sysaccount/"; my %accounts; if (opendir(my $dh, "/home/allowkeeper/$sysaccount")) { while (my $filename = readdir($dh)) { if ($filename =~ /allowed_([a-zA-Z0-9._-]+)\.(ip|partial|private)/) { $accounts{$1} = 1; } } closedir($dh); } return R('OK', value => [sort keys %accounts]); } sub is_valid_ttl { my %params = @_; my $ttl = $params{'ttl'}; my $seconds; if ($ttl =~ /^\d+$/) { return R('OK', value => {seconds => $ttl + 0}); } elsif ($ttl =~ m{^(\d+[smhdwy]*)+$}i) { while ($ttl =~ m{(\d+)([smhdwy])?}gi) { if ($2 eq 'y') { $seconds += $1 * 86400 * 365 } elsif ($2 eq 'w') { $seconds += $1 * 86400 * 7 } elsif ($2 eq 'd') { $seconds += $1 * 86400 } elsif ($2 eq 'h') { $seconds += $1 * 3600 } elsif ($2 eq 'm') { $seconds += $1 * 60 } else { $seconds += $1 } } return R('OK', value => {seconds => $seconds + 0}); } return R('KO_INVALID_PARAMETER', msg => "Invalid TTL ($ttl), expected an amount of seconds, or a duration string such as '2d8h15m'"); } # used by groupList and accountList sub build_re_from_wildcards { my %params = @_; my $wildcards = $params{'wildcards'}; my $implicit_contains = $params{'implicit_contains'}; # to avoid modifying the caller's array my @relist = @$wildcards; # qr// is true, so return undef if there's nothing to build return R('OK', value => undef) if !@relist; for (@relist) { if ($implicit_contains) { # if we have a word without any ? or *, guess that the user expects a "contains" behavior, i.e. *item* $_ = '*' . $_ . '*' if not /[\*\?]/; } $_ = quotemeta; s/\\\*/.*/g; s/\\\?/./g; $_ = '^' . $_ . '$'; } my $stringified = join("|", @relist); return R('OK', value => qr/$stringified/); } 1;