-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: djc-safe-eval - Jinja's sandboxed python expression evaluation #17
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?
Conversation
| quick-xml = "0.38.3" | ||
|
|
||
| # Ruff dependencies | ||
| ruff_annotate_snippets = { path = "crates/djc-safe-eval/submodules/ruff/crates/ruff_annotate_snippets" } |
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.
See the README of djc-safe-eval.
I used Ruff's Python AST parser which is written in Rust. I wanted to use a Rust-based Python AST parser, because this way the Python parser can generate metadata and pass it directly to the main Django template parser.
djc-safe-eval parser collects metadata about:
- what variables the overall expression needs, and
- what variables it assignes to the global context.
In both cases, I capture the variable name, start/end indices, and line/col.
This sort of metadata will be used for:
- Perf optimization - if we know what variables the expression uses, and their values don't change, we can reuse cached results.
- Linter would need the info about what variables are needed and where to find them.
- Note: The trick for evaluating in-template expressions like Vue's linter does is that the linter constructs a new temporary JS/TS file and pastes the expression in it. And then runs the TS linter on the file, collects the errors, and maps the errors from the dummy file to the actual position of the expression in the original template. So the same could be done but with Python and Pyright.
So that's why I wanted to use a Rust-based Python AST parser.
Well and the Ruff project has made one. But I had problem using it as a dependency.
Because the package, ruff_python_ast, is not published on crates.io. But this shouldn't be a problem, because in Rust you can set a dependency a git URL with branch/tag.
But I couldn't get it working. So it might be that it doesn't work when a package is a "workspace" package, AKA Rust's monorepo structure.
So as a workaround I have set up git submodules inside crates/djc-safe-eval/submodules/ruff. Right now it's inside djc-safe-eval because that was the only package that needed the Ruff's code.
After the git submodule was set up, I was able to set the ruff packages as dependencies by setting local file paths.
But as you can see below in this file, because they are not packaged as crates on crates.io, I also had to specify all the sub-dependencies that these Ruff packages used.
It feels like this could be a mess to update.
However, the Python AST parser already supports t-strings that were introduced in Python 3.14. And so until at least py3.15, we won't have to touch this.
| [submodule "crates/djc-safe-eval/submodules/ruff"] | ||
| path = crates/djc-safe-eval/submodules/ruff | ||
| url = https://github.com/astral-sh/ruff.git | ||
| # tag = 0.14.0 |
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.
This was the first time that I was using git modules. I'm surprised that it's actually managed quite implicitly?
To change a commit/tag/branch of the "submodule", you have to:
- Navigate to that directory, e.g.
crates/djc-safe-eval/submodules/ruff - Use regular git commands to change the current head, e.g.
git checkout 0.15.0 - And then navigate back and commit the change:
cd ../../.. git add .gitmodules crates/djc-safe-eval/submodules/ruff git commit -m "Update Ruff submodule to 0.15.0"
What I find strange about this is that you have to use git to find out what commit/tag/branch the submodule is on.
That's why I adding this tag = 0.14.0 comment, to keep these git submodules "pinned".
I documented the entire process in djc-safe-eval's README.
| # And `cargo test` command doesn't allow to exclude crates based on patterns. | ||
| # | ||
| # 1. Get all directories in our `crates/` folder | ||
| packages=$(find crates -maxdepth 1 -mindepth 1 -type d | \ |
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.
This relates to testing in CI.
So since the djc-safe-eval package contains ruff dependencies as git submodules, then simply calling cargo test will also run tests for those ruff packages. And there is one that fails. And because this is in CI, it blocked the pipeline.
So I wanted to exclude the Ruff packages from running their tests when in CI.
But the problem is that cargo test doesn't allow to exclude packages by pattern. Then I could've used ^ruff and call it a day.
Second option was to list all local packages, so only our packages run. But that's not a good design - I'm 100% that I'd forget to add a new Rust package to this file if I created one.
So the compromise here is to have a bit of script that lists all OUR packages (those in crates/), and then format the cargo test command to use only those packages:
cargo test -p <package> -p <package>
| m.add_function(wrap_pyfunction!(set_html_attributes, m)?)?; | ||
|
|
||
| // Safe eval | ||
| m.add_function(wrap_pyfunction!(safe_eval, m)?)?; |
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.
This exposes safe_eval via djc_core package:
from djc_core import safe_eval|
|
||
| For more details, examples, and advanced usage, see [`crates/djc-safe-eval/README.md`](crates/djc-safe-eval/README.md). | ||
|
|
||
| > **WARNING!** Just like Jinja2 and Django's templating, none of these are 100% bulletproof solutions! |
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.
Very important: As the warning says, my take is that none of the 3 - Django templates, Jinja2, and this safe_eval new feature - that none of them are truly safe.
Because they all work by blocking known unsafe behavior. So if a new sneaky approach is exploited, tough luck.
Instead, for these libraries to be safe, we'd have to do the opposite - assume that all functions calls are dangerous by default, and allow only what the user marks as safe.
This isn't a problem as long as all the templates are defined by the first party (AKA the developers themselves).
But we should discourage people from setting up servers that blindly render templates submitted from the outside.
And instead people should follow a pattern as I've seen in Notion, Slack, etc, where the server defines the building blocks, and user can submit only a serialized representation of the content (like the JSON example below; dunno if this pattern has a name).
I can imagine that if there were projects needing to render templates from outside, that it could be fairly easy to create a package on top of django-components that would take the JSON serialization and render actual components in its place. But that's for another time...
| 1. Parsing the Python expression into an AST using `ruff_python_parser` | ||
| 2. Validating the AST against a set of allowed nodes -> Unsupported syntax raises error. | ||
| 3. Transforming specific nodes so we can intercept them: | ||
| - Variables → `variable("name")` |
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.
Here's the summary of how the package works and what are the transformations that it does on AST level.
So for example if you have an expression
x + [n + 1 for n in [1, 2, 3]] + obj.method(f"foo {bar:>10!r}")Then it would get transformed into something like this (Comments and whitespace added for clarity):
(
variable("x")
# `n` is not wrapped in `variable()`
# because it comes from comprehension
+ [n + 1 for n in [1, 2, 3]]
+ call(
# obj.method
attribute(
variable("obj"),
"method",
),
# f"foo {bar:>10!r}"
format(
"foo {}",
# format spec, AKA args you pass to built-in format()
# See https://docs.python.org/3/library/functions.html#format
(variable('bar'), 'r', '>10')
)
)So:
- Literals are left as is, e.g.
[1, 2, 3] - Most operations are left as is, e.g.
x + b - All "external" variables are replaced with
variable(...), func calls withcall(fn, *arg, **kwargs), etc. - "internal" variables, defined by comprehensions or lambdas, are NOT replaced
This transformed code is what would run each time when the expression is being evaluated.
The implementation of variable(), call(), etc, is managed by djc-safe-eval, to safely introduce the values of variables, to call functions, etc, at runtime.
To compare this with Jinja, Jinja intercepts ALL operations. So even something like a + b would become add(a, b). Instead I wanted to minimize the overhead. I don't see a point in intercepting unary (x++) or binary operations (x + y). I understand that it made sense in Jinja, because Jinja was designed to allow "admins" to control what operations are permitted within the templates.
But for django-components, my goal is to make those expressions match Python behaviour, so that we can then have a linter that can tell us which variables are which inside the Python expression. And so giving control over whether a + b is allowed or not does not make sense.
| You can use the walrus operator `x := val` to assign a value to the context object. Assigned variable is then accessible to the rest of the expression: | ||
|
|
||
| ```py | ||
| compiled = safe_eval("(y := x * 2)") |
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.
Another important part is that these expressions can be used to assign variables to the context, similar to Jinja's {% set %} or django-cotton's c-vars.
This is because Python has the walrus operator, (x := y).
Walrus operator is a valid expression (meaning that it returns a value that can be further used).
This is IMO amazing, because using a very simple rule "only python expressions":
-
We get a way to assign variables, which is fully Pythonic.
-
We DON'T have to worry about deletions (
del x), augmented assignments (x += 2), or type annotations (x: int = 3), because all of these are STATEMENTS. -
Walrus op doesn't allow:
- Assigning to attributes or keys, e.g.
(x.y := 123)or(x["y"] := 123) - Assigning to a tuple, e.g.
(x, y := [1, 2])
So walrus op always has to assign to a single valid variable. So this works great for the in-template use, where we can practically convert
(x := 3)intocontext["x"] = 3. - Assigning to attributes or keys, e.g.
So really walrus op is IMO the perfect feature for these Python expressions.
Edge cases
-
Walrus operator can be used also inside comprehensions or lambdas:
# Comprehension compiled = safe_eval("[(x := y) for y in [1, 2, 3]]") context = {} result = compiled(context) print(context) # {"x": 3} # Lambda compiled = safe_eval("fn_with_callback(on_done=lambda res: (data := res))") context = {"fn_with_callback": fn_with_callback} result = compiled(context) print(context) # {"data": {...}, "fn_with_callback": fn_with_callback}, }
NOTE: This differs from regular Python, where walrus variables inside a function
will NOT leak out. But by allowing them to leak out, we can assign to the context those variables which are available only inside callbacks -
If you try to assign a variable to the same name as an existing comprehension or
lambda arguments, you will get a SyntaxError:safe_eval("[(y := y) for y in [1, 2, 3]]") # SyntaxError safe_eval("lambda x: (x := 123))") # SyntaxError
NOTE: Again, this behaviour slightly differs from regular Python. Python raises SyntaxError inside comprehensions, but NOT in functions. We raise SyntaxError in both for consistency.
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.
This is super niche, right? Is there a need to assign variables in templates? We're exposing walrus in versions of python that don't have it, if I understand this correctly? Seems like a big increase in surface area with very little gain.
|
|
||
| ### Mark functions as unsafe | ||
|
|
||
| Use the `@unsafe` decorator to mark functions as unsafe in expressions. |
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.
Since I based this implementation on Jinja's sandbox logic, I also included their @unsafe decorator, so that people can tag their custom functions as not to be evaluated inside expressions.
|
|
||
| ### Custom validators | ||
|
|
||
| `safe_eval` can accept extra validators. These are run **in addition to** the rules defined in [What is unsafe?](#what-is-unsafe) |
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.
Initially my thinking was that:
- this
safe_evalpackage should contain generic validation - In django-components, we should define Django-specific validation, for example to disallow calling
Modelmethods inside the expressions.
That's why I added these "custom validators".
I think in the end they won't be necessary in django-components. Because Django already adds alters_data = True to the model methods. And Jinja's sandboxing takes this flag into consideration.
But I left these custom validators, in case we find out there's a need to block something.
| # NameError: name 'undefined_var' is not defined | ||
| # | ||
| # 1 | my_var + undefined_var | ||
| # ^^^^^^^^^^^^^ |
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.
One thing that I'm really happy with is that when an error occurs inside the expression, we format the error message to include the position in the original expression.
One shortcoming of this, though, is that this works only for the operations that we had intercepted, like assignment, function call, etc.
When you have an expression that raises because of a non-intercepted syntax, we're unable to pin-point where the error happened. And so in that case the entire expression is underlined.
E.g. like in this case, which trips up on addition (+), which is NOT intercepted. And so the entire expression is underlined:
compiled = safe_eval("a + b")
result = compiled({"a": 5, "b": "x"})
# TypeError: unsupported operand type(s) for +: 'int' and 'str'
#
# 1 | a + b
# ^^^^^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.
This is fantastic!
|
|
||
| ### What is unsafe? | ||
|
|
||
| Here's a list of all unsafe scenarios that will trigger `SecurityError`: |
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.
This section describes what safe_eval considers "unsafe".
This is based on Jinja's sandbox logic.
However, I went a step further:
-
Jinja is OK with calling
eval(),exec(), and similar, if they were exposed to the template. I disallowed them entirely. If one needs to calleval()from inside a template, they can wrap it in another function. -
I disallowed using
str.format()andstr.format_map(). These are problematic because you can use them to print out "private" attributes on objects, bypassing the validation, e.g."{obj._secret}".format(obj=my_var) # NO SecurityError!
Jinja solves this by wrapping
str.formatin a different function at runtime, which safely evalautes the string.Instead, for simplicity, I set it so that calling
str.formatorstr.format_mapat runtime will raise an error. Instead, users should use f-strings, which we can intercept.f"{obj._secret}" # will raise SecurityError
| ### Unsupported syntax | ||
|
|
||
| - **Statements**: assignments (`=`), augmented assignments (`+=`, `-=`), `del`, `import`, class/function definitions, `return`, `yield`, etc. | ||
| - **Async/Await**: async comprehensions, `await` expressions |
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.
For simplicity, these Python expressions do NOT suppport async features like async comprehensions, async for, await/async, etc.
Partially because promises (AKA coroutines or whatever they are called in Python) have some fields that Jinja considers to be unsafe.
And partially because the rest of django-components doesn't support async anyway, so there's no point in using async at the moment.
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.
walrus should be here too!
| pub use codegen::generate_python_code; | ||
| pub use transformer::{Token, TransformResult, transform_expression_string}; | ||
|
|
||
| pub fn safe_eval(source: &str) -> Result<String, String> { |
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.
This file defines the public API for other Rust packages
| from djc_core.djc_safe_eval import safe_eval, unsafe, SecurityError | ||
|
|
||
| if hasattr(djc_core, "__all__"): | ||
| __all__ += ["safe_eval", "unsafe", "SecurityError"] |
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.
And this file defines the Python API
| use ruff_source_file::LineEnding; | ||
|
|
||
| /// Generate Python code from a transformed AST expression | ||
| pub fn generate_python_code(expr: &ast::Expr) -> String { |
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.
This a formatter. Meaning that you give it a Python AST and it formats it back into Python code that can be pased to exec or eval
| //! - SyntaxError when walrus assignments conflict with comprehension iteration variables or lambda parameters | ||
| //! | ||
| // Python AST types |
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.
This file is the heart of this PR. Also see the docstring at the top (the comments section starting with //!).
Below, here on lines 110-250 is the list of ALL Python AST nodes, and whether safe_eval supports it.
|
|
||
| /// The main entry point for transforming an expression string. | ||
| /// Returns the transformed expression along with tokens for variables used and assigned. | ||
| pub fn transform_expression_string(source: &str) -> Result<TransformResult, String> { |
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.
This is the entrypoint to this file that is called from the safe_eval defined in djc-safe-eval/src/lib.rs
|
|
||
| /// Our custom AST transformer that validates and transforms Python expressions | ||
| /// to make them safe for evaluation in a sandboxed environment. | ||
| pub struct SandboxTransformer { |
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.
SandboxTransformer holds the state that's needed when transforming the expression's AST.
|
|
||
| impl Transformer for SandboxTransformer { | ||
| /// Override the default visit_expr to implement our validation and transformation logic | ||
| fn visit_expr(&self, expr: &mut Expr) { |
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.
And here is how the actual tranformation works. This is actually similar to how AST transformation API is designed in Python with their NodeVisitor. This class / struct defines methods like visit_<node_name>, e.g. visit_expr, visit_comprehension, etc.
90% of the code is inside the visit_expr, because we support only expressions, and expressions can contain only other expressions or comprehensions.
Then, inside visit_expr, there's a match statement on line 620. And for each kind of expression, there is its own logic. E.g.:
- For literals, we don't do anything.
- For data structures, comprehensions or ternary if/else, we only call
visit_exprrecursively for each child. - For features like variables, function calls, attribute access, etc, we wrap the original AST node in a function call, so e.g.
my_varbecomesvariable("my_var").
| @@ -0,0 +1,2419 @@ | |||
| #[cfg(test)] | |||
| mod tests { | |||
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.
In Rust, the tests are usually written within the same file where the original logic was defined. But to test for various combinations of features, I ended up with A LOT of tests, so I moved them to a separate file, because Cursor was having hard time editing the file.
| return a + b | ||
|
|
||
|
|
||
| class TestSyntax: |
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.
These tests in TestSyntax class are the same as in Rust's djc-safe-eval/tests/transformer.rs. I had LLM write a script to copy and transform those tests into Python.
In Rust, those tests only checked that the transformed Python code is correct.
Here, we also actually evaluate those strings, so we also check that runtime error and types are correct.
|
|
||
| # These tests were based on Jinja2s `test_security.py` file, Jinja v3.1.6 | ||
| # https://github.com/pallets/jinja/blob/5ef70112a1ff19c05324ff889dd30405b1002044/tests/test_security.py | ||
| class TestSecurityJinjaCompat: |
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.
Here's a set of tests tha tcaptures how this package compares against Jinja:
- We also disallow access to attributes and keys with underscores
- We additionally disallow use of
eval,input, etc - We allow methods that mutate objects, e.g.
dict.clear(). Jinja has this OFF by default, but can be optionally turned on. - Jinja disallows access to
obj.func_code. This was internal attribute present in Python 2 (see stackoverflow). Since we're NOT supporting Python 2, IMO we don't have to check for that field. - Jinja allows
str.format, e.g."{obj.__class__}".format(obj=date()). We disallow it and instead ask users to use f-string, e.g.f"date().__class__". - Just like in Jinja, custom functions can be declared as unsafe either using the
@unsafedecorator, or adding thealters_data = Trueattribute.
| T = TypeVar("T", bound=Callable) | ||
|
|
||
|
|
||
| def _format_error_with_context( |
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.
This file contains the logic that formats the errors nicely to underline the part of the expression that caused the error. e.g.
(a := 1 + my_var)
^^^^^^
NameError: name 'my_var' is not defined
So far I was saying that operations like variable or attribute access get transformed into function calls like variable("my_var") or attribute(obj, "attr").
But the truth is that these functions receive also some extra metadata. Each function receives the context, full source code of the expression, and a tuple of start/end indices of the section that is being evaluted.
So e.g. attribute() call may actually look like this:
attribute(context, source, (0, 12), obj, "attr")Then, there's this decorator @error_context on line 129.
What it does is that wraps the call to e.g. attribute() in try/except. And if the inner function fails, it takes the expression's source code and the start/end indices to format the error message with the arrows pointing to where the error happened.
As said earlier, because this works only for those parts of the Python code that was intercepted with our functions, then if the error occurs e.g. in number addition (1 + "a"), then this won't underline just this operation, because we don't intercept addition. In that case the entire expression is underlined.
| pass | ||
|
|
||
|
|
||
| def safe_eval( |
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.
This is where it's all put together for the Python API as safe_eval().
- it accepts optional validator functions
- pre-compiles the given Python expression into an executable function
- defines the interceptor functions like
variable()(at the bottom of this file) - And returns a new function that accepts a context object and evaluates the code with it.
The way this is implemented, when you call safe_eval(), then eval() and the inner function are generated only ever once. So no matter how many times you then call the compiled function, it doesn't have to compile/generate anything more, it just runs its body:
compiled = safe_eval("1 + a")
# No compiling when executing the function
compiled({"a": 1})
compiled({"a": 2})
compiled({"a": 3})|
|
||
| # Create evaluation namespace with wrapped functions | ||
| # These are captured in the closure of the lambda function we'll create | ||
| eval_namespace = { |
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.
And here you can see the variables that are exposed to the expression that we pass to eval(). You can see here that none of the user-defined variables are here. Those have to be introduced by calling one of these methods.
|
|
||
| try: | ||
| # Compile the code but don't execute it | ||
| compiled_code = compile(lambda_code, f"Expression <{source}>", "eval") |
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.
Didn't know this but Python has a separate builtin function for "compiling" and "evaluating" code.
Compiling only builds the Python "bytecode". It parses the string so it can detect syntax errors, but doesn't actually run the code.
It's only at eval() when the code actually runs.
The two are separated so that we can set the original expression as the filename of the code.
What this does is that when there is an error inside the expression, the Python error traceback will look something like this:
Expression<1 + "a">:1: TypeError: unsupported operand type(s) for +: 'int' and 'str'
| """Look up a variable in the evaluation context, e.g. `my_var`""" | ||
| if not is_safe_variable(var_name): | ||
| raise SecurityError(f"variable '{var_name}' is unsafe") | ||
| return __context[var_name] |
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.
Here is the implementation of the intercepted functions like variable(), etc.
You can see that they just run some checks are defined in sandbox.py, and then perform the actual operation.
E.g. in this case when we come across a variable, we check the contet object for that key.
| Useful when the Python expression itself comes from an untrusted source. | ||
| Based on the Jinja v3.1.6 sandbox implementation. | ||
| See https://github.com/pallets/jinja/blob/5ef70112a1ff19c05324ff889dd30405b1002044/src/jinja2/sandbox.py |
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.
And this file defines the security features of safe_eval(). You can see in this link that it is based on Jinja v3.1.6.
| # We check for these as it may happen that these functons were passed as variables, | ||
| # and the user may try to call them. | ||
| # Dictionary mapping unsafe functions to replacement messages. | ||
| _UNSAFE_BUILTIN_FUNCTION_NAMES = { |
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.
And this is a security feature that Jinja lacks - these are builtin functions that we will refuse to execute within the template.
Basically anything that could bypass attribute or key access, eval, etc.
This is by no means fool-proof. I'm sure that there's more functions in Python's standard library that could be used to bypass the safety measures.
However, it kinda makes sense to do it for builtins, as they are automatically available, so higher chance that somesome passed them to the template.
But it'd be impractical to go over ALL python modules, and cehck all their variables / methods for potentially unsafe ones.
| } | ||
|
|
||
|
|
||
| def unsafe(f: F) -> F: |
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.
This and following functions are based on the original Jinja's sandbox.py
|
One more reason why I think these Python expression inside templates are the way to go is because they will also offer a clear path forward when decoupling django-components from Django. These Python expressions can be used to replace:
So basically we can say that these 2 are Django-specific. And so while in Django you might have a template like so: <div>
{{ my_var.attr.callable|default:"none"|upper }}
</div>In v3, we could keep only these Python expressions. In which case the template would be changed to: <div>
{{ upper(my_var.attr.callable() or "none") }}
</div> |
|
@EmilStenstrom Would you have a look at this? Mainly the comments and READMEs |
|
One more realization - These python expression would also solve how to add support for django-coton's HTML-like components (django-components/django-components#794). There, the unsolved issue was how to differentiate between HTML attributes and Python attributes (component inputs). But with Python expression using
So these two would be equivalent:
|
|
@JuroOravec This is almost 10.000 lines of code, which is unreviewable. But I've looked at the comments above to get a feel for the features it provides. Overall, I think this is the way to go. But I have some thoughts on the syntax:
|
|
@EmilStenstrom Thanks for the feedback! TL;DR - I'm fine with disabling/omitting walrus op, as it can be substituted with Re: Walrus
Walrus was added in Python 3.8, so all supported versions of Django / django-components already work with it! Walrus op would make sense so that django-components could replace Jinja ( Alternatively similar could be achieved with Re: Parentheses
I was thinking of your suggestion too, but it's got its issue too, which is that
So that's why I chose something that's intentionally different from already existing And there's not many good options left:
Why not
|

Overview
This is a re-implementation of Jinja2's sandboxed evaluation logic, built in Rust using the Ruff Python parser.
In Jinja the expressions you write inside
{{ }}and{% %}are similar to actual Python code:{{ foo.bar() }} {{ foo['bar']() }}In Django templates you get something which looks similar, but is very limited:
obj.attrnot, ternary operator, etc.This makes development in Django annoying, even with django-components:
get_template_data()get_template_data(), or again as a separate filter.So I want to bring proper Python expressions to Django templates, which will be more exact yet more powerful.
This is last of the "extensions" to the Django template syntax.
The idea is that in django-components, Python expressions can be used anywhere where Django templates allow regular variables. Python expressions would be identified by parentheses:
They can be also used with filters like normal variables / literals:
This "python expressions in templates" consists of 3 parts:
djc-safe-evalpackage.djc-safe-evalpackage and use it This PR introduces the underlying code that would make it possible.See the READMEs for more info like notes on security, performance, the definition of "unsafe" code, etc.