fix: replace eval() with an AST parser for template expressions

Empty __builtins__ is not a sandbox, so a template expression could
reach os.system. Walk the AST with an allow-list instead. Also fixes
app names missing from api/v1/urls.py and a wrong settings path in the
generated pyproject.toml.
This commit is contained in:
sankalp committed 2026-09-28 14:00:43 +05:45
1 parent 7c3ec11070
commit 482ff13dd3
5 files changed
+428 -20

No files matched your search

+392 -17
View File
@@ -1,5 +1,375 @@
"""
Safe template expression evaluation for djinit.
Templates use a tiny, comment-style logic language:
# @IF <expression> ... # @ELSEIF <expression> ... # @ELSE ... # @ENDIF
# @LOOP <var> in <expression> ... # @ENDLOOP
[[ variable ]] variable / attribute / index substitution
Expressions are evaluated by walking the parsed syntax tree with an explicit
allow-list. ``eval()`` is never used: restricting ``__builtins__`` is *not* a
sandbox, because object graphs reachable from ordinary literals (for example
``().__class__.__base__.__subclasses__()``) still hand out ``os.system`` and
friends. An explicit interpreter has no such escape hatch.
"""
import ast
import operator
import re
from typing import Any, Dict, List
from collections.abc import Mapping
from typing import Any, Dict, List, Optional
__all__ = ["InFileLogicParser", "SafeExpressionError"]
class SafeExpressionError(ValueError):
"""Raised when a template expression is malformed or uses a disallowed feature."""
# The only names an expression may reference besides the render context. These are
# side-effect free helpers; nothing that can import, open, execute or mutate.
_SAFE_BUILTINS: Mapping[str, Any] = {
"True": True,
"False": False,
"None": None,
"abs": abs,
"all": all,
"any": any,
"bool": bool,
"dict": dict,
"enumerate": enumerate,
"float": float,
"int": int,
"len": len,
"list": list,
"max": max,
"min": min,
"reversed": reversed,
"sorted": sorted,
"str": str,
"zip": zip,
}
# Attribute names that must never be reachable, even on an otherwise harmless
# object. Together with rejecting every ``_``-prefixed name this closes the usual
# attribute-hop escape routes (``__class__``, ``__globals__``, ``__subclasses__``…).
# ``format``/``format_map`` are included because the *format string* they receive is
# resolved with ``getattr`` inside CPython, which would sidestep the checks below.
_FORBIDDEN_ATTRIBUTES = frozenset(
{
"__import__",
"breakpoint",
"compile",
"delattr",
"eval",
"exec",
"exit",
"format",
"format_map",
"fork",
"getattr",
"globals",
"input",
"locals",
"open",
"popen",
"quit",
"setattr",
"spawn",
"system",
"vars",
}
)
# Calling a method is only allowed on plain data values. Without this, a context
# value that happens to be a module, class or function would expose its whole
# (dangerous) surface through dotted access.
_PLAIN_DATA_TYPES = (str, bytes, bytearray, bool, int, float, complex, list, tuple, dict, set, frozenset, Mapping)
# Guards against cheap resource exhaustion such as ``2 ** 99999999``.
_MAX_EXPONENT = 1000
class _SafeExpressionEvaluator(ast.NodeVisitor):
"""Interprets a validated expression tree against the render context."""
_BINARY_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
}
_UNARY_OPERATORS = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
ast.Not: operator.not_,
ast.Invert: operator.invert,
}
_COMPARISON_OPERATORS = {
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
ast.Is: operator.is_,
ast.IsNot: operator.is_not,
ast.In: lambda a, b: operator.contains(b, a),
ast.NotIn: lambda a, b: not operator.contains(b, a),
}
def __init__(self, context: Mapping[str, Any]) -> None:
# Outermost scope is the render context; comprehension bodies push their own
# dicts so loop variables never leak into the surrounding scope.
self._scopes: List[Any] = [context]
# -- entry point ---------------------------------------------------------
def evaluate(self, tree: ast.Expression) -> Any:
return self.visit(tree.body)
def generic_visit(self, node: ast.AST) -> Any:
raise SafeExpressionError(f"{type(node).__name__} is not allowed in template expressions")
# -- literals ------------------------------------------------------------
def visit_Constant(self, node: ast.Constant) -> Any:
return node.value
def visit_List(self, node: ast.List) -> list:
return [self.visit(element) for element in node.elts]
def visit_Tuple(self, node: ast.Tuple) -> tuple:
return tuple(self.visit(element) for element in node.elts)
def visit_Set(self, node: ast.Set) -> set:
return {self.visit(element) for element in node.elts}
def visit_Dict(self, node: ast.Dict) -> dict:
result: dict = {}
for key_node, value_node in zip(node.keys, node.values):
if key_node is None:
raise SafeExpressionError("Dictionary unpacking is not allowed")
result[self.visit(key_node)] = self.visit(value_node)
return result
def visit_JoinedStr(self, node: ast.JoinedStr) -> str:
return "".join(str(self.visit(value)) for value in node.values)
def visit_FormattedValue(self, node: ast.FormattedValue) -> str:
value = self.visit(node.value)
if node.conversion == 115: # !s
return f"{value:s}"
if node.conversion == 114: # !r
return repr(value)
if node.conversion == 97: # !a
return ascii(value)
if node.format_spec is not None:
return format(value, self.visit(node.format_spec))
return str(value)
# -- names and attributes ------------------------------------------------
def visit_Name(self, node: ast.Name) -> Any:
return self._lookup(node.id)
def _lookup(self, name: str) -> Any:
for scope in reversed(self._scopes):
if name in scope:
return scope[name]
if name in _SAFE_BUILTINS:
return _SAFE_BUILTINS[name]
raise SafeExpressionError(f"Unknown name: {name!r}")
def visit_Attribute(self, node: ast.Attribute) -> Any:
return self._resolve_attribute(self.visit(node.value), node.attr)
@staticmethod
def _resolve_attribute(value: Any, name: str) -> Any:
if name.startswith("_") or name in _FORBIDDEN_ATTRIBUTES:
raise SafeExpressionError(f"Access to attribute {name!r} is not allowed")
if isinstance(value, Mapping):
# Templates routinely index plain dicts with dotted syntax.
try:
return value[name]
except KeyError:
raise AttributeError(name) from None
resolved = getattr(value, name)
if callable(resolved) and not isinstance(value, _PLAIN_DATA_TYPES):
raise SafeExpressionError(f"Access to method {name!r} is not allowed here")
return resolved
# -- operators -----------------------------------------------------------
def visit_BoolOp(self, node: ast.BoolOp) -> Any:
if isinstance(node.op, ast.And):
result: Any = True
for value in node.values:
result = self.visit(value)
if not result:
return result
return result
result = False
for value in node.values:
result = self.visit(value)
if result:
return result
return result
def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
handler = self._UNARY_OPERATORS.get(type(node.op))
if handler is None:
raise SafeExpressionError(f"Unary operator {type(node.op).__name__} is not allowed")
return handler(self.visit(node.operand))
def visit_BinOp(self, node: ast.BinOp) -> Any:
handler = self._BINARY_OPERATORS.get(type(node.op))
if handler is None:
raise SafeExpressionError(f"Binary operator {type(node.op).__name__} is not allowed")
left = self.visit(node.left)
right = self.visit(node.right)
if isinstance(node.op, ast.Pow) and isinstance(left, int) and isinstance(right, int):
if abs(right) > _MAX_EXPONENT:
raise SafeExpressionError("Exponent is too large")
return handler(left, right)
def visit_Compare(self, node: ast.Compare) -> bool:
left = self.visit(node.left)
for operator_node, comparator_node in zip(node.ops, node.comparators):
handler = self._COMPARISON_OPERATORS.get(type(operator_node))
if handler is None:
raise SafeExpressionError(f"Comparison {type(operator_node).__name__} is not allowed")
right = self.visit(comparator_node)
if not handler(left, right):
return False
left = right
return True
def visit_IfExp(self, node: ast.IfExp) -> Any:
return self.visit(node.body) if self.visit(node.test) else self.visit(node.orelse)
# -- subscripts ----------------------------------------------------------
def visit_Subscript(self, node: ast.Subscript) -> Any:
value = self.visit(node.value)
key = self._resolve_subscript_key(node.slice, value)
try:
return value[key]
except TypeError as exc:
raise SafeExpressionError(f"Cannot subscript {type(value).__name__}") from exc
def _resolve_subscript_key(self, node: ast.AST, value: Any) -> Any:
if isinstance(node, ast.Slice):
return slice(
self._resolve_subscript_bound(node.lower),
self._resolve_subscript_bound(node.upper),
self._resolve_subscript_bound(node.step),
)
if isinstance(node, ast.Tuple):
return tuple(self.visit(element) for element in node.elts)
return self.visit(node)
def _resolve_subscript_bound(self, node: Optional[ast.AST]) -> Optional[Any]:
return None if node is None else self.visit(node)
# -- calls ---------------------------------------------------------------
def visit_Call(self, node: ast.Call) -> Any:
if any(keyword.arg is None for keyword in node.keywords):
raise SafeExpressionError("** unpacking is not allowed in calls")
args = [self.visit(argument) for argument in node.args]
kwargs = {keyword.arg: self.visit(keyword.value) for keyword in node.keywords}
callee = self.visit(node.func)
if not callable(callee):
raise SafeExpressionError(f"{callee!r} is not callable")
return callee(*args, **kwargs)
# -- comprehensions ------------------------------------------------------
def visit_ListComp(self, node: ast.ListComp) -> list:
result: list = []
self._run_comprehension(node.generators, 0, lambda: result.append(self.visit(node.elt)))
return result
def visit_SetComp(self, node: ast.SetComp) -> set:
result: set = set()
self._run_comprehension(node.generators, 0, lambda: result.add(self.visit(node.elt)))
return result
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> tuple:
result: list = []
self._run_comprehension(node.generators, 0, lambda: result.append(self.visit(node.elt)))
return tuple(result)
def visit_DictComp(self, node: ast.DictComp) -> dict:
result: dict = {}
self._run_comprehension(
node.generators,
0,
lambda: result.__setitem__(self.visit(node.key), self.visit(node.value)),
)
return result
def _run_comprehension(self, generators: List[ast.comprehension], index: int, emit) -> None:
if index >= len(generators):
emit()
return
generator = generators[index]
for value in self._as_iterable(self.visit(generator.iter)):
self._scopes.append({})
try:
self._assign(generator.target, value)
if all(self.visit(condition) for condition in generator.ifs):
self._run_comprehension(generators, index + 1, emit)
finally:
self._scopes.pop()
@staticmethod
def _as_iterable(value: Any) -> Any:
if isinstance(value, (str, bytes)) or not hasattr(value, "__iter__"):
raise SafeExpressionError(f"{type(value).__name__} is not iterable")
return value
def _assign(self, target: ast.AST, value: Any) -> None:
if isinstance(target, ast.Name):
self._scopes[-1][target.id] = value
return
if isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (tuple, list)):
if len(target.elts) != len(value):
raise SafeExpressionError("Cannot unpack mismatched number of loop variables")
for element, item in zip(target.elts, value):
self._assign(element, item)
return
raise SafeExpressionError("Unsupported loop target")
def safe_eval(expression: str, context: Optional[Mapping[str, Any]] = None) -> Any:
"""Evaluate a template expression without ``eval()``.
Args:
expression: The expression source, e.g. ``"use_tailwind and len(app_names) > 0"``.
context: Names available to the expression.
Raises:
SafeExpressionError: If the expression is empty, malformed or disallowed.
"""
source = expression.strip()
if not source:
raise SafeExpressionError("Empty expression")
try:
tree = ast.parse(source, mode="eval")
except SyntaxError as exc:
raise SafeExpressionError(f"Invalid expression syntax: {source!r}") from exc
return _SafeExpressionEvaluator(context or {}).evaluate(tree)
class InFileLogicParser:
@@ -11,18 +381,21 @@ class InFileLogicParser:
def __init__(self, context: Dict[str, Any] = None):
self.context = context or {}
def _evaluate(self, expression: str) -> Any:
"""Evaluate ``expression`` against the current context, safely."""
return safe_eval(expression, self.context)
def _get_value(self, key: str) -> str:
"""
Supports nested/dotted access like [[ user.name ]] or [[ settings["DEBUG"] ]].
For simplicity, it uses eval in the provided context for complex expressions,
but falls back to safe dictionary lookup for simple keys.
Dotted names are resolved against the context, and arbitrary expressions are
evaluated by the safe interpreter. Anything that cannot be resolved is
returned verbatim so a missing context key stays visible in the output.
"""
key = key.strip()
try:
# Try evaluating the expression in the context
return str(eval(key, {"__builtins__": {}}, self.context))
except (NameError, SyntaxError, KeyError, TypeError, AttributeError):
# Fallback for common patterns or just return the key as is if it fails
return str(self._evaluate(key))
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
return f"[[ {key} ]]"
def render(self, template_text: str, context: Dict[str, Any] = None) -> str:
@@ -44,8 +417,8 @@ class InFileLogicParser:
if stripped.startswith("# @IF "):
expr = stripped[6:].strip()
try:
result = bool(eval(expr, {"__builtins__": {}}, self.context))
except Exception:
result = bool(self._evaluate(expr))
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
result = False
stack.append([result, result])
i += 1
@@ -65,8 +438,8 @@ class InFileLogicParser:
current_stack[0] = False
else:
try:
result = bool(eval(expr, {"__builtins__": {}}, self.context))
except Exception:
result = bool(self._evaluate(expr))
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
result = False
current_stack[0] = result
if result:
@@ -113,13 +486,15 @@ class InFileLogicParser:
i += 1
continue
parts = stripped[7:].strip().split(" in ")
if len(parts) == 2:
parts = stripped[7:].strip().split(" in ", 1)
if len(parts) == 2 and parts[0].strip() and parts[1].strip():
var_name = parts[0].strip()
iterable_name = parts[1].strip()
try:
iterable = eval(iterable_name, {"__builtins__": {}}, self.context)
except Exception:
iterable = self._evaluate(iterable_name)
if not hasattr(iterable, "__iter__"):
raise TypeError(f"{iterable_name!r} is not iterable")
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
iterable = []
# Capture loop body
@@ -197,14 +572,14 @@ class InFileLogicParser:
post_content = expr_parts[1].lstrip()
try:
if bool(eval(expr, {"__builtins__": {}}, self.context)):
if bool(self._evaluate(expr)):
# If Case 1, we want pre_content. If Case 2, we want post_content.
# Usually, if pre_content is empty (or just whitespace), it's Case 2.
if pre_content:
final_lines.append(pre_content + post_content)
else:
final_lines.append(post_content)
except Exception:
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
pass
i += 1
continue
+20
View File
@@ -8,6 +8,7 @@ import os
from djinit.core.base import BaseService
from djinit.templater import template_engine
from djinit.utils.common import CommonUtils
from djinit.utils.django import DjangoHelper
class FileCreator(BaseService):
@@ -195,12 +196,26 @@ class FileCreator(BaseService):
"Created requirements.txt with Django dependencies",
)
def _registered_app_modules(self) -> list:
"""Dotted module paths of the apps that end up in INSTALLED_APPS.
The unified and single layouts use the generated package itself as their
only app, so they have no separate ``app_names`` to report.
"""
if self.metadata.get("unified_structure"):
return ["apps"]
if self.metadata.get("single_structure"):
return [self.module_name]
return CommonUtils.calculate_app_module_paths(self.app_names, self.metadata)
def create_readme(self) -> None:
context = {
"project_name": self.project_name,
"app_names": self.app_names,
"app_list": self._registered_app_modules(),
"predefined_structure": bool(self.metadata.get("predefined_structure")),
"unified_structure": bool(self.metadata.get("unified_structure")),
"single_structure": bool(self.metadata.get("single_structure")),
"module_name": self.module_name,
}
self._render_and_create_file("README.md", "project/readme.md-tpl", context, "Created README.md file")
@@ -293,6 +308,11 @@ class FileCreator(BaseService):
"Created apps/core/base.py",
)
# Every app that ends up in USER_DEFINED_APPS needs a real app module,
# otherwise the generated project cannot start.
for app_name in self.app_names or ["users", "core"]:
DjangoHelper.startapp(app_name, apps_dir, f"apps.{app_name}")
# Create api directory structure
api_dir = os.path.join(self.project_root, "api")
CommonUtils.create_directory_with_init(api_dir, "Created api/__init__.py")
+3
View File
@@ -35,6 +35,7 @@ class SetupCreator:
if not self.app_names:
self.app_names = ["users", "core"]
self.project_creator.app_names = self.app_names
self.file_creator.app_names = self.app_names
elif self.metadata.get("unified_structure"):
self.metadata.setdefault("project_module_name", "core")
@@ -43,12 +44,14 @@ class SetupCreator:
if not self.app_names:
self.app_names = []
self.project_creator.app_names = self.app_names
self.file_creator.app_names = self.app_names
elif self.metadata.get("single_structure"):
self.metadata.setdefault("nested_apps", False)
if not self.app_names:
self.app_names = []
self.project_creator.app_names = self.app_names
self.file_creator.app_names = self.app_names
def create(self) -> bool:
"""Run the full setup process."""
@@ -1,5 +1,5 @@
[project]
name = "[[ project_name ]]"
name = "[[ package_name ]]"
version = "0.1.0"
description = ""
readme = "README.md"
@@ -61,7 +61,7 @@ ignore = [
]
[tool.ruff.lint.isort]
known-first-party = ["[[ project_name ]]"]
known-first-party = ["[[ module_name ]]"]
[tool.ruff.lint.per-file-ignores]
"[[ project_name ]]/settings/*.py" = ["F403", "F405"]
"[[ module_name ]]/settings/*.py" = ["F403", "F405"]
@@ -9,6 +9,16 @@ A Django project folder structure is generated with [djinitx/djinit/dj](https://
- Essential dependencies and utilities
- Production-ready configuration
# @IF app_list
## Apps
Registered in `[[ module_name ]]/settings/base.py`:
# @LOOP app in app_list
- `[[ app ]]`
# @ENDLOOP
# @ENDIF
## Setup
### Using Just (Recommended)