Parser recursion limit#24810
Conversation
|
Hey, could someone please kick off CI for this. Also, FWIW I have this working with monty and avoiding stack overflows both in AST parsing for the bytecode compiler and type checking in pydantic/monty#391. |
|
|
Thank you. This is an improvement, but I'm not convinced it is the proper fix; it only moves the needle on for which programs the parser aborts. But it isn't sufficient, e.g., to protect against allocation failures because the program's too large. I also checked, and neither TypeScript nor Rust implements the same treatment. Instead, the common approach across parsers is to:
In the end, protecting against denial-of-service attacks isn't specific to stack overflows. The same protection must be in place to handle the exploitation of bugs (in the parser or elsewhere). Which is why I wouldn't consider this a security bug (it certainly adds a few more guardrails, but it doesn't prevent them). |
|
I get where you're coming from, but the fact is if you limit the code length, stack overflow is one of the only DOS risks in the parser. @zanieb suggested you don't have the bandwidth to rewrite the recursion to a loop, and I certainly don't - so the choice is between adding this improvement, and not adding this improvement. I'd therefore really appreciate it if you accepted this improvement. But I don't get it if you're willing to merge, I'll just use ruff crates from my branch and attempt to keep it up to date. (If you are considering rewriting the parser to a loop, please consider making it available as an iterator so we can avoid the overhead of allocating before the first IR) |
I think there was some misunderstanding of what "rewriting" to a loop means. I'm not suggesting that we rewrite the parser to a loop. Instead, the idea is to unroll the recursion by using a loop, similar to what we do in |
|
I'm fine going ahead with this if we address the following issues:
|
|
great, I'll get those things fixed as soon as I have time. |
|
Claude says cpython uses 201, I'll use that CPython 3.14 nesting cutoffs
How this was measured
#!/usr/bin/env python3
"""Generate deeply nested Python source and check how CPython handles it.
Mirrors the patterns covered by the parser-recursion-limit tests so we can
compare Ruff's behaviour with what CPython's own parser/compiler accepts.
Usage:
python scripts/parse_recursion_check.py <pattern> <depth> [--run]
Patterns:
parens ((((1))))
lists [[[[1]]]]
binary_paren 1+(1+(1+(1)))
nested_def def f(): def f(): ... pass
match_pattern match x: case ((((y)))): pass
fstring f"{f"{f"{1}"}"}"
By default the generated source is written to a temp file and CPython is
invoked with ``python -c "compile(open(path).read(), path, 'exec')"`` so we
exercise the parser without executing the code. Pass ``--run`` to actually
``exec`` it.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
def gen_parens(depth: int) -> str:
return "(" * depth + "1" + ")" * depth + "\n"
def gen_lists(depth: int) -> str:
return "[" * depth + "1" + "]" * depth + "\n"
def gen_binary_paren(depth: int) -> str:
return "1+(" * depth + "1" + ")" * depth + "\n"
def gen_nested_def(depth: int) -> str:
lines = []
for i in range(depth):
lines.append("\t" * i + "def f():")
lines.append("\t" * depth + "pass")
return "\n".join(lines) + "\n"
def gen_match_pattern(depth: int) -> str:
return "match x:\n case " + "(" * depth + "y" + ")" * depth + ": pass\n"
def gen_fstring(depth: int) -> str:
# f"{ f"{ ... f"{1}" ... }" }"
return 'f"' + '{f"' * (depth - 1) + '{1}' + '"}' * (depth - 1) + '"\n'
GENERATORS = {
"parens": gen_parens,
"lists": gen_lists,
"binary_paren": gen_binary_paren,
"nested_def": gen_nested_def,
"match_pattern": gen_match_pattern,
"fstring": gen_fstring,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pattern", choices=sorted(GENERATORS))
parser.add_argument("depth", type=int)
parser.add_argument(
"--run",
action="store_true",
help="exec the file instead of just compiling it",
)
parser.add_argument(
"--out",
type=Path,
help="write the generated source to this path (default: a temp file)",
)
parser.add_argument(
"--print-only",
action="store_true",
help="just write the file and print its path; don't invoke Python",
)
parser.add_argument(
"--python",
default=sys.executable,
help="python executable to invoke (default: the current one)",
)
args = parser.parse_args()
src = GENERATORS[args.pattern](args.depth)
if args.out is not None:
path = args.out
path.write_text(src)
else:
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, prefix=f"recur_{args.pattern}_"
)
tmp.write(src)
tmp.close()
path = Path(tmp.name)
print(f"wrote {len(src)} bytes to {path}", file=sys.stderr)
if args.print_only:
print(path)
return 0
if args.run:
cmd = [args.python, str(path)]
else:
# Compile-only: parses + compiles but does not execute the body.
cmd = [
args.python,
"-c",
f"import sys; "
f"src = open({str(path)!r}).read(); "
f"compile(src, {str(path)!r}, 'exec'); "
f"print('ok', file=sys.stderr)",
]
print(f"running: {' '.join(cmd)}", file=sys.stderr)
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.stdout:
sys.stdout.write(proc.stdout)
if proc.stderr:
sys.stderr.write(proc.stderr)
print(f"exit code: {proc.returncode}", file=sys.stderr)
return proc.returncode
if __name__ == "__main__":
sys.exit(main())A bash binary search over each pattern finds the boundary: for pat in parens lists binary_paren match_pattern fstring nested_def; do
lo=1; hi=300
while [ $((hi - lo)) -gt 1 ]; do
mid=$(( (lo + hi) / 2 ))
res=$(python3 scripts/parse_recursion_check.py "$pat" "$mid" 2>&1 \
| grep -E "^(ok|SyntaxError|RecursionError|IndentationError|MemoryError)" \
| head -1)
if [ "$res" = "ok" ]; then lo=$mid; else hi=$mid; fi
done
echo "$pat: max ok=$lo, first fail=$hi"
done |
af72bbf to
7b61191
Compare
|
@MichaReiser I think I've covered all your points. |
|
Thank you and thanks for the table. Do you know if CPython tracks separate counts, e.g. allows 200 nested parentheses and 200 nested lists but fails on the 201st nested parentheses? Or is it what you implemented in this PR, a global recursion limit? |
|
CPython has a single shared counter for all open delimiters ( So CPython is basically the same as ruff now, accept Ruff's counter also covers statement nesting (def |
|
Hey, @MichaReiser anything else you need here? |
|
There's nothing that I need from you. I just need to find time to review the change (the entire team is traveling this week) |
|
Hmm, the performance regression is an issue and I consider a blocker. The PR also doesn't correctly handle right-recursive expressions like I'm leaning towards leaving it as is. |
Typing conformance resultsNo changes detected ✅Current numbersThe percentage of diagnostics emitted that were expected errors held steady at 89.36%. The percentage of expected errors that received a diagnostic held steady at 85.49%. The number of fully passing files held steady at 88/134. |
Memory usage reportMemory usage unchanged ✅ |
|
|
Thank you. This change still regress performance by 2-3%, and I'm not sure this rare edge case is worth trading on perf. |
|
I think it'd be a loss if they need to run on a fork to prevent stack overflows. I'm not sure what the solution for the performance is though. |
|
I'll ask Codex to give it a try. |
I don't think that makes much sense, given you can generate a stack overflow as easily as uvx python -c "open('stack_overflow.py', 'w').write('[' * 5000)" && uvx ty check stack_overflow.pyor uvx python -c "open('stack_overflow.py', 'w').write('[' * 5000)" && uvx ruff check stack_overflow.py |
|
If you really want to avoid the overhead in your tools. I can re-implement the depth check as a generic, where you can use a no-op variant. Let me know if you'd prefer that? |
|
Na I think that would be a worse outcome. We should find a way to fix this at zero cost. I'm working on it in the background. |
538ed32 to
b02bcaa
Compare
|
I think it's worth fixing. I don't think it's a priority for us to fix this |
|
How can this not be a priority? Stack overflow and hard crash of the process is trivial to cause with one (long) line of code. Cloud/managed execution of ruff and ty is only going to increase, so solving these sorts of vulnerability should surely be a priority. Apart from that, for my use case of the crate, stack overflow is a major DOS risk. |
|
Only fixing this in the parser will not be sufficient for Ruff or ty. All visitor code can run into stack overflows, even after limiting the depth in the parser. ty has a lot of recursive calls, again, fixing those requires an approach other than the change here in the parser. It also is simply not as high a priority as a stable ty release. I'm not saying it isn't important, it's just not as important as some other work. |
|
Okay, we can agree to disagree on the priority. Who (who's AI) should fix the comments above, me or @charliermarsh? I'm a bit unclear who this sits with now. We're intending to relaunch hackmonty.com next week, so we need to get something into monty by then, but it could be on a branch. |
|
I discussed with Micha. I will address the comments and see through to merging, then look into some of the longer-term alternatives. |
|
great, thank you. |
Swapped the EllipsisLiteral placeholder in parse_lhs_expression for the standard expression-recovery node - an ExprName with id: Name::empty() and ctx: ExprContext::Invalid - built inline rather than via parse_missing_name (which would add a redundant "Expected an identifier" error on top of the RecursionLimitExceeded that enter_recursion already records).
b02bcaa to
6f33fce
Compare
|
Awesome. Thanks everyone for helping on this! |
## Summary Partial fix for astral-sh#22930. Without this malicious or machine generated code could cause a stack overflow with something as simple as `'(' * 5000 + '1' + ')' * 5000`. I decided to do the simplest thing and have a limit that's always applied with a reasonable default. Since: * the overhead of this check will be tiny * it seems inconceivable that anyone will want to have no limit ## Test Plan PR includes tests. --------- Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
## Summary Partial fix for astral-sh#22930. Without this malicious or machine generated code could cause a stack overflow with something as simple as `'(' * 5000 + '1' + ')' * 5000`. I decided to do the simplest thing and have a limit that's always applied with a reasonable default. Since: * the overhead of this check will be tiny * it seems inconceivable that anyone will want to have no limit ## Test Plan PR includes tests. --------- Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
Summary
Partial fix for #22930.
Without this malicious or machine generated code could cause a stack overflow with something as simple as
'(' * 5000 + '1' + ')' * 5000.I decided to do the simplest thing and have a limit that's always applied with a reasonable default. Since:
Test Plan
PR includes tests.