Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 197 additions & 15 deletions src/vyos_op_run.ml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@

(* Global constants *)
let op_def_file = "/usr/share/vyos/op_cache.json"
let permissions_file = "/etc/vyos/operators.json"
let vyos_admin_group_name = "vyattacfg"

(* List of commands that operators are unconditionally denied to execute. *)
let admin_only_commands = [
(* Configuration mode operations *)
["configure"];
["commit"];
["commit-confirm"];
["confirm"];
(* XXX: executing a shell through a wrapper that does setuid 0
provides a ready shell escape and defeats the purpose.
We cannot allow operator-level users to execute shells
in VRFs and network namespaces
at least until we find a way to drop privileges
after attaching to the VRF/netns but before executing the commands.
*)
["execute"; "shell"];
]

(* Execution options *)
type options = {
Expand Down Expand Up @@ -50,6 +69,9 @@ let internal_error msg = raise (Internal_error msg)
exception Command_error of string
let command_error msg = raise (Command_error msg)

exception Permission_error
let permission_error () = raise Permission_error

exception Incomplete_command

(* Logging setup routines *)
Expand Down Expand Up @@ -83,6 +105,12 @@ let read_command_definitions () =
let () = close_in ic in
data

let read_permissions () =
let ic = open_in permissions_file in
let data = Yojson.Safe.from_channel ic in
let () = close_in ic in
data

let find_child_node op_node word =
let open Yojson.Safe.Util in
let res = member word op_node in
Expand Down Expand Up @@ -137,6 +165,148 @@ let get_virtual_tag_node node =
| `Null -> None
| _ -> Some res

(* Command permission checks *)

let rec permission_matches perm cmd =
match perm, cmd with
| [], _ ->
(* If all terms of the permission matched
all words of the command, the command is allowed --
we follow the implicit approach
"every permission includes all sub-commands"
*)
true
| _, [] ->
(* If the command is shorter than the permission spec,
it means the permission is more specific.
E.g., 'show interfaces ethernet' permission
should reject attempts to run 'show interfaces',
since its intent is to allow access only to Ethernet. *)
false
| (p :: ps), (c :: cs) ->
(* Permission term can be either a command word
or a special token '*' that matches any command. *)
if (p = c) || (p = "*") then permission_matches ps cs
else false

let group_perms_match perms group cmd =
let get_group_perms perms g =
let perms = Yojson.Safe.Util.path
["groups"; g; "command_policy"; "allow"] perms
in
match perms with
| Some v ->
(try
v |>
Yojson.Safe.Util.to_list |>
List.map (fun j -> Yojson.Safe.Util.to_list j |> List.map Yojson.Safe.Util.to_string)
with _ ->
Printf.ksprintf internal_error
"Command policy for group %s is not a list of string lists" g)
| None -> Printf.ksprintf internal_error
"Configuration does not define command policy for group %s" g
in
let rec perm_list_matches ps cmd =
match ps with
| [] -> false
| p :: ps ->
if permission_matches p cmd then true
else perm_list_matches ps cmd
in
let group_perms = get_group_perms perms group in
perm_list_matches group_perms cmd

let is_admin () =
let admin_group = Unix.getgrnam vyos_admin_group_name in
let user_groups = Unix.getgroups () in
match (Array.find_opt ((=) admin_group.gr_gid) user_groups) with
| Some _ -> true
| None -> false

let has_unsafe_characters cmd =
(* XXX: this function is highly restrictive now,
until we are completely certain that shell escape
cannot happen down the line inside VyOS op mode scripts.
Alphanumeric characters, hyphens, dots, and whitespace
should allow operator users to use most commands
that take interface names, FQDNs, and config entities
like IPsec peer names.
Notable exceptions are:
- 'show bgp regexp': regexes naturally require '$' and other
patently shell-unsafe characters.
- 'monitor traffic interface eth0 filter':
PCAP filters use '!', '&&' and '||',
although people can use 'and', 'or', 'not'
to get around the restriction.
- 'add system image': requires non-alphanumeric characters
for URLs.
*)
try
let _ = Pcre2.exec ~pat:{|[^a-zA-Z0-9_\-\.\s]|} cmd in
let () =
Printf.fprintf stderr "Command [%s] contains special characters \
that operator-level users are not allowed to use\n" cmd
in
true
with Not_found -> false

let is_admin_only_command cmd =
let rec prefix_matches prefix target =
match prefix, target with
| [], _ ->
(* The target matched every word of the prefix,
so it's a match.
*)
true
| _, [] ->
(* The target is shorter than the prefix,
so it's not a match.
*)
false
| (p :: ps), (t :: ts) ->
if p = t then prefix_matches ps ts
else false
in
let res = List.find_opt (fun p -> prefix_matches p cmd) admin_only_commands in
match res with
| None -> false
| Some _ -> true

let check_command_permissions perms cmd =
let rec aux perms groups cmd =
match groups with
| [] -> permission_error ()
| g :: gs ->
if group_perms_match perms g cmd then ()
else aux perms gs cmd
in
(* VyOS admins can execute any commands without restrictions *)
if is_admin () then () else
(* Operators are not allowed to execute commands
with potentially unsafe characters in them *)
if has_unsafe_characters (String.concat " " cmd) then permission_error () else
(* Some commands are unconditionally denied to operators *)
if is_admin_only_command cmd then permission_error () else
(* Operator level users must always be in groups
with defined command policies
*)
let username = Unix.getlogin () in
let groups = Yojson.Safe.Util.path ["users"; username] perms in
match groups with
| None | Some (`List []) ->
Printf.ksprintf internal_error "User %s is not assigned to any operator group" username
| Some gs ->
let group_list =
(try
gs |>
Yojson.Safe.Util.to_list |>
List.map Yojson.Safe.Util.to_string
with _ ->
Printf.ksprintf internal_error "The groups field for user %s is not a list of strings"
username)
in
aux perms group_list cmd

(* Command rendering and execution *)
let render_command opts env command_tmpl =
let () = Logs.debug @@ fun m -> m "Command template: %s" command_tmpl in
Expand All @@ -145,25 +315,30 @@ let render_command opts env command_tmpl =
let vyos_command = opts.vyos_command in
Pcre2.replace ~pat:{|\$[@*]|} ~templ:vyos_command command

let run_command opts env command_tmpl =
let run_external_command opts env command_tmpl =
let cmd = render_command opts env command_tmpl in
if opts.dry_run then Printf.printf "%s\n%!" cmd else
let () = Logs.debug @@ fun m -> m "Command to be executed %s" cmd in
let res = Unix.system cmd in
match res with
| Unix.WEXITED 0 -> ()
| _ -> Printf.ksprintf command_error "Execution of command '%s' failed" cmd
| _ ->
(* Many op mode commands return non-zero exit codes on benign errors
such as an unconfigured subsystem,
so we shouldn't show this to the user by default.
*)
Logs.debug @@ fun m -> m "Execution of command '%s' failed" cmd

(* Command lookup *)
let rec find_command opts ?(env=[]) ?(parent="") node cmd_words =
let rec run_vyos_command opts ?(env=[]) ?(parent="") node cmd_words =
match cmd_words with
| w :: ws ->
let () = Logs.debug @@ fun m -> m "Looking up node '%s'" w in
let res = find_child_node node w in
begin match res with
| Some child_node ->
(* It's a normal, fixed command word *)
find_command opts ~env:env ~parent:w child_node ws
run_vyos_command opts ~env:env ~parent:w child_node ws
| None ->
(* It's either an argument of a tag node
or an incorrect command word *)
Expand All @@ -177,9 +352,9 @@ let rec find_command opts ?(env=[]) ?(parent="") node cmd_words =
begin match ws with
| [] ->
let command = get_command node_data in
run_command opts env command
run_external_command opts env command
| _ as ws ->
find_command opts ~env:env ~parent:w node ws
run_vyos_command opts ~env:env ~parent:w node ws
end
| "node", Some vtn ->
(* It's a command that can be used either by itself or with an argument. *)
Expand All @@ -188,12 +363,12 @@ let rec find_command opts ?(env=[]) ?(parent="") node cmd_words =
| [] ->
let vtn_data = get_node_data vtn in
let command = get_command vtn_data in
run_command opts env command
run_external_command opts env command
| _ ->
(* In the case of a virtual tag node, we take the parent (for variable substitution purposes)
from the upper level.
*)
find_command opts ~env:env ~parent:parent vtn ws
run_vyos_command opts ~env:env ~parent:parent vtn ws
end
| "node", None | "leafNode", None ->
let path = get_path node_data in
Expand Down Expand Up @@ -223,7 +398,7 @@ let rec find_command opts ?(env=[]) ?(parent="") node cmd_words =
in
begin match command with
| Some command ->
run_command opts env command
run_external_command opts env command
| None ->
raise Incomplete_command
end
Expand Down Expand Up @@ -276,19 +451,26 @@ let () =
let debug = if debug then true else options.debug in
let () = setup_logging debug in
let op_defs = read_command_definitions () in
let permissions = read_permissions () in
let () = Unix.setuid 0 in
let () = Logs.debug @@ fun m -> m "Executing VyOS command [%s]" (String.concat " " args) in
try
find_command options ~env:[] ~parent:"" op_defs args
with
check_command_permissions permissions args;
run_vyos_command options ~env:[] ~parent:"" op_defs args
with
| Permission_error ->
Printf.fprintf stderr "You do not have a permission to execute VyOS command [%s]\n"
options.vyos_command;
exit 1
| Invalid_command msg ->
Printf.fprintf stderr "Invalid command [%s]: %s" options.vyos_command msg;
Printf.fprintf stderr "Invalid command [%s]: %s\n" options.vyos_command msg;
exit 1
| Command_error msg ->
Printf.fprintf stderr "%s" msg;
Printf.fprintf stderr "%s\n" msg;
| Incomplete_command ->
Printf.fprintf stderr "Incomplete command: %s" options.vyos_command;
Printf.fprintf stderr "Incomplete command: %s\n" options.vyos_command;
exit 2
| Internal_error msg ->
Printf.fprintf stderr "Internal error: %s" msg;
Printf.fprintf stderr "Internal error: %s\n" msg;
exit 255