diff options
| author | Daniil Baturin <daniil@baturin.org> | 2025-08-21 13:46:01 +0100 |
|---|---|---|
| committer | Daniil Baturin <daniil@baturin.org> | 2025-08-28 13:30:51 +0100 |
| commit | dd7ca05d33174c5fe2e35c5d0b7610973a02039c (patch) | |
| tree | 7c1068ac1ac166342352e8214c2e1e5400993b14 /src | |
| parent | 9285b34718203a62a9620c34b0bc50ba85d839d6 (diff) | |
| download | vyos-1x-dd7ca05d33174c5fe2e35c5d0b7610973a02039c.tar.gz vyos-1x-dd7ca05d33174c5fe2e35c5d0b7610973a02039c.zip | |
vyos-op-run: T7745: add a command permission checking mechanism
Diffstat (limited to 'src')
| -rw-r--r-- | src/vyos_op_run.ml | 212 |
1 files changed, 197 insertions, 15 deletions
diff --git a/src/vyos_op_run.ml b/src/vyos_op_run.ml index 0a8f35134..0767ba298 100644 --- a/src/vyos_op_run.ml +++ b/src/vyos_op_run.ml @@ -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 = { @@ -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 *) @@ -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 @@ -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 @@ -145,17 +315,22 @@ 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 @@ -163,7 +338,7 @@ let rec find_command opts ?(env=[]) ?(parent="") node cmd_words = 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 *) @@ -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. *) @@ -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 @@ -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 @@ -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 |
