Repository navigation
fix(security): rm floor reads reserved words, empty substitutions and ${...} spans #23840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,7 @@ | |
| #: :func:`_segments` keeps a redirection and its fd prefix in one word (below). | ||
| _HOME_WORDS = frozenset({"~", "$HOME", "${HOME}"}) | ||
| _CUT_CHARS = frozenset(";&|\n`()") | ||
| _SUBST = "\1" # a substitution: may expand to nothing, unlike a literal ``\0`` | ||
| #: A STANDALONE fd prefix (a bare number or ``{name}``) directly before a redirection | ||
| #: operator: ``2>`` and ``{fd}>`` redirect fd 2 / a named fd, but the ``2`` in | ||
| #: ``/tmp/2>x`` is part of the path, so the prefix counts only at a word boundary. | ||
|
|
@@ -75,27 +76,33 @@ def _target_kind(word: str) -> "str | None": | |
| return "home" if base in _HOME_WORDS else None | ||
|
|
||
|
|
||
| def _segments(command: str) -> "list[str]": | ||
| def _segments(command: str, track: bool = True) -> "list[str]": | ||
| """*command* cut at separators the shared quote machine marks live; stops at a heredoc.""" | ||
| out: list[str] = [] | ||
| seg: list[str] = [] | ||
| in_redirect = False | ||
| #: closers of open ``${…}`` / ``$[…]``: a ``#`` or ``<<`` there is data, so | ||
| #: neither a comment nor a heredoc starts until they close | ||
| expansion: list[str] = [] | ||
| pos, state = 0, 0 | ||
| while pos < len(command): | ||
| text, resume, resume_state = command[pos:], len(command), 0 | ||
| dollars = 0 # live ``$`` run ending here: ``$$`` is the PID, never an opener | ||
| for step in _shell._iter_shell_chars(text, state): | ||
| if step.active and text.startswith("<<", step.offset): | ||
| dollars = dollars + 1 if step.active and step.char == "$" else 0 | ||
| live_syntax = step.active and not expansion | ||
| if live_syntax and text.startswith("<<", step.offset): | ||
| break # heredoc bodies are data; the rows and the sandbox own them | ||
| if step.active and step.char == "#" and _shell._opens_comment(text, step.offset): | ||
| if live_syntax and step.char == "#" and _shell._opens_comment(text, step.offset): | ||
| newline = text.find("\n", step.offset) # a comment's quotes open nothing | ||
| resume = len(command) if newline < 0 else pos + newline | ||
| break | ||
| if step.state == 2 and text.startswith("$(", step.offset): | ||
| # bash reads "$(…)" from an unquoted start, then the double quote resumes | ||
| body = text[step.offset + 2 :] | ||
| close, proven = _shell._matching_close_paren(body, 0) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 A
This is an over-refusal, not a bypass, but it is the case Security Scope Review confirms as newly refused on two POSIX legs. Drafting a commit or PR body into a variable through a heredoc is a common agent flow, especially for security fixes that quote the refused command. As long as it stays, that gate stays red. Suggestion: stop at a heredoc start inside the substitution body when looking for the close, or leave the body to the sandbox when a heredoc opens inside it. Add the gate's two commands as allow cases. Rule: a floor change should not newly refuse a legitimate, common workflow without a maintainer decision. |
||
| out.extend(_segments(body[: close - 1 if proven else close])) | ||
| seg.append("\0") | ||
| out.extend(_segments(body[: close - 1 if proven else close], track)) | ||
| seg.append(_SUBST) | ||
| in_redirect = False | ||
| resume, resume_state = pos + step.offset + 2 + close, 2 | ||
| break | ||
|
|
@@ -104,11 +111,15 @@ def _segments(command: str) -> "list[str]": | |
| closer = _shell._backtick_closer(text, step.offset + 1) | ||
| closed = closer != -1 | ||
| end = closer + 1 if closed else len(text) | ||
| out.extend(_segments(text[step.offset + 1 : end - closed])) | ||
| seg.append("\0") | ||
| out.extend(_segments(text[step.offset + 1 : end - closed], track)) | ||
| seg.append(_SUBST) | ||
| in_redirect = False | ||
| resume, resume_state = pos + end, 2 | ||
| break | ||
| if track and dollars % 2 and text.startswith(("${", "$["), step.offset): | ||
| expansion.append("}" if text[step.offset + 1] == "{" else "]") | ||
| elif step.active and expansion and step.char == expansion[-1]: | ||
| expansion.pop() | ||
| if step.active and in_redirect and step.char == "&": | ||
| seg.append(step.text) # fd-dup operator (``2>&1``), not a command separator | ||
| elif step.active and step.char in _CUT_CHARS: | ||
|
|
@@ -138,11 +149,14 @@ def _segments(command: str) -> "list[str]": | |
| def _rm_wipe_targets(command: str) -> "set[str]": | ||
| """The targets (``root`` / ``home``) a recursive-force ``rm`` in *command* wipes.""" | ||
| kinds: set[str] = set() | ||
| for segment in _segments(command): | ||
| # read with and without ``${…}`` tracking: the untracked reading is the | ||
| # base one, so tracking can only add refusals | ||
| for segment in [*_segments(command), *_segments(command, track=False)]: | ||
| words = _shell._shell_tokens(segment) | ||
| while words: | ||
| if ENV_ASSIGNMENT_RE.match(words[0]): | ||
| words = words[1:] | ||
| if ENV_ASSIGNMENT_RE.match(words[0]) or words[0] in _shell._KEEPS_COMMAND_POSITION: | ||
| # ``then`` / ``do`` / ``!`` / ``time`` … leave the next word the program | ||
| words = words[2:] if words[0] == "time" and words[1:2] == ["-p"] else words[1:] | ||
| continue | ||
| is_redirection, consumes_next = _shell._push_token_redirection(words[0]) | ||
| if is_redirection: | ||
|
|
@@ -152,6 +166,11 @@ def _rm_wipe_targets(command: str) -> "set[str]": | |
| if consumes_next and words: | ||
| words = words[1:] | ||
| continue | ||
| if _SUBST in words[0]: | ||
| # a substitution in command position may expand to nothing | ||
| bare = words[0].replace(_SUBST, "") | ||
| words = [bare, *words[1:]] if bare else words[1:] | ||
| continue | ||
| break | ||
| if not words or _shell._program_basename(words[0]) != "rm": | ||
| continue | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 A comment inside
${x:-$( ... )}no longer hides its quotes, so a home wipe gets throughCause: this delta handles
$(...)and backticks as their own frame only inside double quotes (step.state == 2). An unquoted$(inside an open${...}is now walked inline, andlive_syntaxis false there. Mechanism:# don'tis not read as a comment, so its apostrophe opens a single quote that runs overrm -fr ~up to the'on the next comment line. The span closes at}, so the unclosed-span fallback never runs. Consequence: base and efe5150 refuse the command below, this head allows it, and bash runs the innerrm -fr ~(confirmed with aprintfstand-in):The backtick form and
rm -fr /behave the same way.Suggestion: read the command twice at the top of
_rm_wipe_targets, with and without span tracking, and passtrackinto the nested_segmentscalls:The untracked reading keeps every refusal base had, so the result is deny-only by construction. Add the three forms above as deny rows.
Rule: a change to a deny floor may only add refusals.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 9b008cd: every command is now read with and without
${…}tracking and the targets are joined, as you suggested. The three forms are deny rows and fail on 94abebc.🤖 AI-assisted comment 🤖