Skip to content
This repository was archived by the owner on May 27, 2026. It is now read-only.

Commit dd7ca05

Browse files
committed
vyos-op-run: T7745: add a command permission checking mechanism
1 parent 9285b34 commit dd7ca05

1 file changed

Lines changed: 197 additions & 15 deletions

File tree

src/vyos_op_run.ml

Lines changed: 197 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@
1818

1919
(* Global constants *)
2020
let op_def_file = "/usr/share/vyos/op_cache.json"
21+
let permissions_file = "/etc/vyos/operators.json"
22+
let vyos_admin_group_name = "vyattacfg"
23+
24+
(* List of commands that operators are unconditionally denied to execute. *)
25+
let admin_only_commands = [
26+
(* Configuration mode operations *)
27+
["configure"];
28+
["commit"];
29+
["commit-confirm"];
30+
["confirm"];
31+
(* XXX: executing a shell through a wrapper that does setuid 0
32+
provides a ready shell escape and defeats the purpose.
33+
We cannot allow operator-level users to execute shells
34+
in VRFs and network namespaces
35+
at least until we find a way to drop privileges
36+
after attaching to the VRF/netns but before executing the commands.
37+
*)
38+
["execute"; "shell"];
39+
]
2140

2241
(* Execution options *)
2342
type options = {
@@ -50,6 +69,9 @@ let internal_error msg = raise (Internal_error msg)
5069
exception Command_error of string
5170
let command_error msg = raise (Command_error msg)
5271

72+
exception Permission_error
73+
let permission_error () = raise Permission_error
74+
5375
exception Incomplete_command
5476

5577
(* Logging setup routines *)
@@ -83,6 +105,12 @@ let read_command_definitions () =
83105
let () = close_in ic in
84106
data
85107

108+
let read_permissions () =
109+
let ic = open_in permissions_file in
110+
let data = Yojson.Safe.from_channel ic in
111+
let () = close_in ic in
112+
data
113+
86114
let find_child_node op_node word =
87115
let open Yojson.Safe.Util in
88116
let res = member word op_node in
@@ -137,6 +165,148 @@ let get_virtual_tag_node node =
137165
| `Null -> None
138166
| _ -> Some res
139167

168+
(* Command permission checks *)
169+
170+
let rec permission_matches perm cmd =
171+
match perm, cmd with
172+
| [], _ ->
173+
(* If all terms of the permission matched
174+
all words of the command, the command is allowed --
175+
we follow the implicit approach
176+
"every permission includes all sub-commands"
177+
*)
178+
true
179+
| _, [] ->
180+
(* If the command is shorter than the permission spec,
181+
it means the permission is more specific.
182+
E.g., 'show interfaces ethernet' permission
183+
should reject attempts to run 'show interfaces',
184+
since its intent is to allow access only to Ethernet. *)
185+
false
186+
| (p :: ps), (c :: cs) ->
187+
(* Permission term can be either a command word
188+
or a special token '*' that matches any command. *)
189+
if (p = c) || (p = "*") then permission_matches ps cs
190+
else false
191+
192+
let group_perms_match perms group cmd =
193+
let get_group_perms perms g =
194+
let perms = Yojson.Safe.Util.path
195+
["groups"; g; "command_policy"; "allow"] perms
196+
in
197+
match perms with
198+
| Some v ->
199+
(try
200+
v |>
201+
Yojson.Safe.Util.to_list |>
202+
List.map (fun j -> Yojson.Safe.Util.to_list j |> List.map Yojson.Safe.Util.to_string)
203+
with _ ->
204+
Printf.ksprintf internal_error
205+
"Command policy for group %s is not a list of string lists" g)
206+
| None -> Printf.ksprintf internal_error
207+
"Configuration does not define command policy for group %s" g
208+
in
209+
let rec perm_list_matches ps cmd =
210+
match ps with
211+
| [] -> false
212+
| p :: ps ->
213+
if permission_matches p cmd then true
214+
else perm_list_matches ps cmd
215+
in
216+
let group_perms = get_group_perms perms group in
217+
perm_list_matches group_perms cmd
218+
219+
let is_admin () =
220+
let admin_group = Unix.getgrnam vyos_admin_group_name in
221+
let user_groups = Unix.getgroups () in
222+
match (Array.find_opt ((=) admin_group.gr_gid) user_groups) with
223+
| Some _ -> true
224+
| None -> false
225+
226+
let has_unsafe_characters cmd =
227+
(* XXX: this function is highly restrictive now,
228+
until we are completely certain that shell escape
229+
cannot happen down the line inside VyOS op mode scripts.
230+
Alphanumeric characters, hyphens, dots, and whitespace
231+
should allow operator users to use most commands
232+
that take interface names, FQDNs, and config entities
233+
like IPsec peer names.
234+
Notable exceptions are:
235+
- 'show bgp regexp': regexes naturally require '$' and other
236+
patently shell-unsafe characters.
237+
- 'monitor traffic interface eth0 filter':
238+
PCAP filters use '!', '&&' and '||',
239+
although people can use 'and', 'or', 'not'
240+
to get around the restriction.
241+
- 'add system image': requires non-alphanumeric characters
242+
for URLs.
243+
*)
244+
try
245+
let _ = Pcre2.exec ~pat:{|[^a-zA-Z0-9_\-\.\s]|} cmd in
246+
let () =
247+
Printf.fprintf stderr "Command [%s] contains special characters \
248+
that operator-level users are not allowed to use\n" cmd
249+
in
250+
true
251+
with Not_found -> false
252+
253+
let is_admin_only_command cmd =
254+
let rec prefix_matches prefix target =
255+
match prefix, target with
256+
| [], _ ->
257+
(* The target matched every word of the prefix,
258+
so it's a match.
259+
*)
260+
true
261+
| _, [] ->
262+
(* The target is shorter than the prefix,
263+
so it's not a match.
264+
*)
265+
false
266+
| (p :: ps), (t :: ts) ->
267+
if p = t then prefix_matches ps ts
268+
else false
269+
in
270+
let res = List.find_opt (fun p -> prefix_matches p cmd) admin_only_commands in
271+
match res with
272+
| None -> false
273+
| Some _ -> true
274+
275+
let check_command_permissions perms cmd =
276+
let rec aux perms groups cmd =
277+
match groups with
278+
| [] -> permission_error ()
279+
| g :: gs ->
280+
if group_perms_match perms g cmd then ()
281+
else aux perms gs cmd
282+
in
283+
(* VyOS admins can execute any commands without restrictions *)
284+
if is_admin () then () else
285+
(* Operators are not allowed to execute commands
286+
with potentially unsafe characters in them *)
287+
if has_unsafe_characters (String.concat " " cmd) then permission_error () else
288+
(* Some commands are unconditionally denied to operators *)
289+
if is_admin_only_command cmd then permission_error () else
290+
(* Operator level users must always be in groups
291+
with defined command policies
292+
*)
293+
let username = Unix.getlogin () in
294+
let groups = Yojson.Safe.Util.path ["users"; username] perms in
295+
match groups with
296+
| None | Some (`List []) ->
297+
Printf.ksprintf internal_error "User %s is not assigned to any operator group" username
298+
| Some gs ->
299+
let group_list =
300+
(try
301+
gs |>
302+
Yojson.Safe.Util.to_list |>
303+
List.map Yojson.Safe.Util.to_string
304+
with _ ->
305+
Printf.ksprintf internal_error "The groups field for user %s is not a list of strings"
306+
username)
307+
in
308+
aux perms group_list cmd
309+
140310
(* Command rendering and execution *)
141311
let render_command opts env command_tmpl =
142312
let () = Logs.debug @@ fun m -> m "Command template: %s" command_tmpl in
@@ -145,25 +315,30 @@ let render_command opts env command_tmpl =
145315
let vyos_command = opts.vyos_command in
146316
Pcre2.replace ~pat:{|\$[@*]|} ~templ:vyos_command command
147317

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

157332
(* Command lookup *)
158-
let rec find_command opts ?(env=[]) ?(parent="") node cmd_words =
333+
let rec run_vyos_command opts ?(env=[]) ?(parent="") node cmd_words =
159334
match cmd_words with
160335
| w :: ws ->
161336
let () = Logs.debug @@ fun m -> m "Looking up node '%s'" w in
162337
let res = find_child_node node w in
163338
begin match res with
164339
| Some child_node ->
165340
(* It's a normal, fixed command word *)
166-
find_command opts ~env:env ~parent:w child_node ws
341+
run_vyos_command opts ~env:env ~parent:w child_node ws
167342
| None ->
168343
(* It's either an argument of a tag node
169344
or an incorrect command word *)
@@ -177,9 +352,9 @@ let rec find_command opts ?(env=[]) ?(parent="") node cmd_words =
177352
begin match ws with
178353
| [] ->
179354
let command = get_command node_data in
180-
run_command opts env command
355+
run_external_command opts env command
181356
| _ as ws ->
182-
find_command opts ~env:env ~parent:w node ws
357+
run_vyos_command opts ~env:env ~parent:w node ws
183358
end
184359
| "node", Some vtn ->
185360
(* 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 =
188363
| [] ->
189364
let vtn_data = get_node_data vtn in
190365
let command = get_command vtn_data in
191-
run_command opts env command
366+
run_external_command opts env command
192367
| _ ->
193368
(* In the case of a virtual tag node, we take the parent (for variable substitution purposes)
194369
from the upper level.
195370
*)
196-
find_command opts ~env:env ~parent:parent vtn ws
371+
run_vyos_command opts ~env:env ~parent:parent vtn ws
197372
end
198373
| "node", None | "leafNode", None ->
199374
let path = get_path node_data in
@@ -223,7 +398,7 @@ let rec find_command opts ?(env=[]) ?(parent="") node cmd_words =
223398
in
224399
begin match command with
225400
| Some command ->
226-
run_command opts env command
401+
run_external_command opts env command
227402
| None ->
228403
raise Incomplete_command
229404
end
@@ -276,19 +451,26 @@ let () =
276451
let debug = if debug then true else options.debug in
277452
let () = setup_logging debug in
278453
let op_defs = read_command_definitions () in
454+
let permissions = read_permissions () in
279455
let () = Unix.setuid 0 in
456+
let () = Logs.debug @@ fun m -> m "Executing VyOS command [%s]" (String.concat " " args) in
280457
try
281-
find_command options ~env:[] ~parent:"" op_defs args
282-
with
458+
check_command_permissions permissions args;
459+
run_vyos_command options ~env:[] ~parent:"" op_defs args
460+
with
461+
| Permission_error ->
462+
Printf.fprintf stderr "You do not have a permission to execute VyOS command [%s]\n"
463+
options.vyos_command;
464+
exit 1
283465
| Invalid_command msg ->
284-
Printf.fprintf stderr "Invalid command [%s]: %s" options.vyos_command msg;
466+
Printf.fprintf stderr "Invalid command [%s]: %s\n" options.vyos_command msg;
285467
exit 1
286468
| Command_error msg ->
287-
Printf.fprintf stderr "%s" msg;
469+
Printf.fprintf stderr "%s\n" msg;
288470
| Incomplete_command ->
289-
Printf.fprintf stderr "Incomplete command: %s" options.vyos_command;
471+
Printf.fprintf stderr "Incomplete command: %s\n" options.vyos_command;
290472
exit 2
291473
| Internal_error msg ->
292-
Printf.fprintf stderr "Internal error: %s" msg;
474+
Printf.fprintf stderr "Internal error: %s\n" msg;
293475
exit 255
294476

0 commit comments

Comments
 (0)