Skip to content

fix: shell-quote directive targets to prevent OS command injection - #382

Open
bronson-calif wants to merge 1 commit into
thatmattlove:mainfrom
bronson-calif:security/fix-directive-command-injection
Open

fix: shell-quote directive targets to prevent OS command injection#382
bronson-calif wants to merge 1 commit into
thatmattlove:mainfrom
bronson-calif:security/fix-directive-command-injection

Conversation

@bronson-calif

@bronson-calif bronson-calif commented Jul 14, 2026

Copy link
Copy Markdown

Description

Directive command construction now shell-quotes the query target on platforms whose command string is executed by a POSIX shell, so an untrusted target can no longer break out of its intended argument.

For FRR/BIRD/OpenBGPD (device_type="linux_ssh") and TNSR (dataplane shell sudo vtysh -c), Construct.format() no longer does a bare str.format. It splits the trusted template into words, fills the fields in each word, then shlex.quotes every word and rejoins:

if self.device.platform in SHELL_PLATFORMS:
    return " ".join(shlex.quote(word.format(**fmt)) for word in shlex.split(command))
return command.format(**fmt)

Because the template is tokenized before the target is substituted, the target always lands inside a single argv word and can never introduce a new one — regardless of content. Legitimate regex syntax ($, |, (), ^$) and prefixes are preserved, and the previously-unquoted OpenBGPD template is covered by the same path. Non-shell NOS platforms keep the plain str.format path, since their CLIs do not parse POSIX quoting.

Separately, QueryTarget now rejects ASCII control characters (\x00–\x1f, \x7f). This closes the newline-injection vector on the non-shell NOS platforms, where netmiko's rstrip-only normalize_cmd otherwise lets an embedded newline execute as a second CLI line. No legitimate BGP query target contains a control character.

There are no directive-template changes — the fix is entirely in the constructor and the query model, so a template can be edited freely without reintroducing the issue.

Scope note on TNSR: TNSR is included in SHELL_PLATFORMS because it shells out. The quoting produces one correctly-quoted POSIX shell line, which is right for the bash that dataplane shell reaches; the TNSR CLI parser in front of that bash has quoting semantics I have not verified against real firmware. FRR/BIRD/OpenBGPD go straight to bash and are unaffected. Happy to drop TNSR and track it separately if preferred.

Related Issues

Fixes #383.

Motivation and Context

The builtin directive templates for the shell-backed platforms interpolate the unauthenticated query target directly into a command string that a POSIX shell then executes, e.g. vtysh -c "show bgp ipv4 unicast regexp {target}". Two things let it break out:

  1. Construct.format() substituted the target with a bare str.format() — no shell escaping.
  2. The default condition="*" rule compiles to re.compile(".+").match(), which is start-anchored and matches any non-empty string, so every input passes validation.

A " in the target closes the quote and a following ; / ` / $(...) reaches the shell; OpenBGPD's template is unquoted, so a bare ; injects directly. The response still returns the legitimate show bgp output, so the injection is not visible in the query result. The net effect is arbitrary command execution on the managed router from a single unauthenticated POST /api/query.

Tests

  • Lint/format: ruff check and ruff format --check pass on all changed files (matches the CI rye lint step); longest new line is 93 chars (limit 100).
  • Existing suite: the change is a no-op for non-shell platforms, so hyperglass/execution/drivers/tests/test_construct.py (a Juniper case) is unaffected.
  • End-to-end: against a full stack (Redis + FRRouting over SSH + a build with this change) on Linux / Python 3.12, the original payload plus quote / $() / backtick / ; / | / & / newline bypass variants no longer execute anything on the router, while legitimate anchored (_65000$), empty-path (^$), and community (65000:100) queries still return level=success.
  • Template sweep: every shipped shell-platform template (FRR/BIRD/OpenBGPD/TNSR) was run through the exact format() logic against that payload set to confirm no target can break out of its argument and that legitimate targets are preserved unchanged.

Builtin directive templates for shell-backed platforms (FRR, BIRD,
OpenBGPD, TNSR) interpolate the unauthenticated query target into a
command string that is executed by a POSIX shell, so a shell
metacharacter in the target could break out of its argument and run
arbitrary commands on the managed router.

- _construct.py: for shell-backed platforms, build the command as a
  shell-quoted argv line (split the trusted template into words, fill
  each word, then shlex.quote each) so the target always stays a single
  literal argument regardless of content. Non-shell NOS platforms keep
  the plain str.format path.
- query.py: reject ASCII control characters in QueryTarget, closing the
  newline-injection vector on the non-shell NOS CLIs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: unauthenticated OS command injection via directive query target

1 participant