Skip to content

Commit 91cfe21

Browse files
committed
v0.1.1: security hardening from post-release audit
Four findings from the v0.1.0 code review, ranked by severity. All four ship together — no behaviour change for clean inputs, just tighter rails for malicious or accidentally-malformed data. 1. CRITICAL — store_property._eval_path replaced with AST-walker Was: eval(path, {"bpy": bpy, "__builtins__": {}}) The empty __builtins__ blocked __import__ but bpy.ops.* and bpy.app.* were wide open, so a poisoned .blend's Studio.custom_paths[].data_path could ship something like "bpy.ops.wm.quit_blender()" that fired on Apply Studio with no warning, no opt-in, no toggle. Now: parse the path with ast.parse(mode='eval'), walk the tree and refuse anything that isn't Expression/Name/Attribute/ Subscript/Constant/Load/Index. Subscripts must use literal str or int indices. The only allowed root identifier is `bpy`. Calls, comprehensions, lambdas, arithmetic, comparisons, dunder names — all rejected with a new UnsafePathError. eval() is still used to evaluate the validated AST, but only AFTER we've proven the AST has no executable parts. This is the standard "validate then eval" pattern; eval is the cheapest way to walk an attribute chain back into a runtime object. 2. HIGH — worker.spawn_next refuses suspicious blend paths Defense in depth: subprocess.Popen with list-form already prevents argv injection, but Blender's own CLI parser could mistake a `-`-prefixed positional for a flag and consume the next argv entry as its value (so a blend_path of "-P" would steal the render-script slot). Also reject NUL bytes (execve weirdness on some platforms). Failed jobs are marked FAILED with a clear error and the worker tries the next pending job. 3. MEDIUM — slack_webhook scheme + host validation Was: any URL accepted, including file:// (read local files via urllib's default opener) and gopher:// (probe internal services). Now: only http and https schemes are allowed, and the URL must parse with a non-empty netloc. Any rejected URL is logged at WARNING and the action returns without making a network call. 4. LOW — preview_cache.cleanup() exception-safe Wrap bpy.utils.previews.remove() in try/except/finally so the global _pcoll is always cleared even if the underlying remove raises. Was: a remove() exception left _pcoll set to a stale ImagePreviewCollection, which then tripped the "left open" ResourceWarning on interpreter shutdown. 13 new tests in tests/blender/test_security.py: - 9 cover the AST-walker — accepts real RNA paths (attribute chains, string subscripts) + rejects calls, lambdas, arithmetic, dunder access, dynamic subscripts, syntax errors, and propagates through the public resolve_value entry point - 4 cover the slack_webhook scheme guard — file://, gopher://, no- host URLs all refused without calling urlopen; valid https URL still reaches urlopen as a happy-path sanity check Manifest bumped to 0.1.1. Total suite: 191/192 (one pre-existing dirty-state reload flake).
1 parent cab0f50 commit 91cfe21

6 files changed

Lines changed: 329 additions & 7 deletions

File tree

stage/blender_manifest.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
schema_version = "1.0.0"
22

33
id = "stage"
4-
version = "0.1.0"
4+
version = "0.1.1"
55
name = "Stage"
66
tagline = "Scene state manager and render queue"
77
maintainer = "Louis Rist <BlueLazyFish@users.noreply.github.com>"

stage/core/post_render.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import subprocess
2020
from pathlib import Path
2121
from typing import Any
22-
from urllib import error as urlerror, request as urlrequest
22+
from urllib import error as urlerror, parse as urlparse, request as urlrequest
2323

2424
import bpy
2525

@@ -121,12 +121,29 @@ def play_sound(action, scene, studio, output_path: str) -> None:
121121

122122
_DEFAULT_SLACK_MESSAGE = "Render complete: {studio} -> {output_path}"
123123

124+
# Only HTTP(S) webhooks are allowed. Without this, a poisoned .blend
125+
# could ship a `file://` or `gopher://` URL and use urllib's default
126+
# handlers to read local files / probe internal services. Slack and
127+
# every comparable service uses HTTPS in practice; HTTP is permitted
128+
# for self-hosted dev relays.
129+
_ALLOWED_WEBHOOK_SCHEMES: frozenset[str] = frozenset({"http", "https"})
130+
124131

125132
def slack_webhook(action, scene, studio, output_path: str) -> None:
126133
url = action.target.strip()
127134
if not url:
128135
_log.warning("SLACK_WEBHOOK: empty URL")
129136
return
137+
parsed = urlparse.urlparse(url)
138+
if parsed.scheme not in _ALLOWED_WEBHOOK_SCHEMES:
139+
_log.warning(
140+
"SLACK_WEBHOOK: refusing scheme %r — only %s are allowed",
141+
parsed.scheme, sorted(_ALLOWED_WEBHOOK_SCHEMES),
142+
)
143+
return
144+
if not parsed.netloc:
145+
_log.warning("SLACK_WEBHOOK: URL has no host: %r", url)
146+
return
130147
template = action.message or _DEFAULT_SLACK_MESSAGE
131148
text = _expanded_message(template, scene, studio, output_path)
132149
payload = json.dumps({"text": text}).encode("utf-8")

stage/core/store_property.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,87 @@
4141
COLOR = 'COLOR'
4242

4343

44+
class UnsafePathError(ValueError):
45+
"""Raised when a stored data_path contains constructs we don't allow."""
46+
47+
48+
# Allowed AST nodes for a constrained path expression. Notably absent:
49+
# Call (no function calls), BinOp/Compare/BoolOp (no expressions), Lambda /
50+
# Comprehension / GeneratorExp / IfExp / Starred / FormattedValue / JoinedStr
51+
# (no fancy syntax). The only things needed for real RNA paths are attribute
52+
# access and constant subscripts.
53+
_ALLOWED_NODES: tuple[type, ...] = (
54+
ast.Expression, ast.Name, ast.Attribute, ast.Subscript, ast.Constant,
55+
ast.Load, ast.Index, # Index is for older Python AST shapes; harmless on 3.9+
56+
)
57+
58+
# Only `bpy` is permitted as a root identifier. Everything else (e.g.
59+
# `__import__`, builtins, captured names) is refused outright.
60+
_ALLOWED_ROOTS: frozenset[str] = frozenset({"bpy"})
61+
62+
63+
def _validate_ast(node: ast.AST) -> None:
64+
"""Walk the parsed expression and raise on anything not in the allowlist.
65+
66+
Constrains the path to attribute access (`a.b.c`) and constant subscripts
67+
(`a["key"]`, `a[3]`). Refuses calls, comprehensions, arithmetic, etc.
68+
This is the security boundary — once an AST passes here, eval() can be
69+
used safely on the original source string because we've proven it has
70+
no executable parts.
71+
"""
72+
for child in ast.walk(node):
73+
if not isinstance(child, _ALLOWED_NODES):
74+
raise UnsafePathError(
75+
f"Disallowed AST node {type(child).__name__} in stored data_path"
76+
)
77+
if isinstance(child, ast.Subscript):
78+
# Subscript index must be a constant string or int. (On Python
79+
# 3.9+ the slice is the value directly; older Pythons wrap in
80+
# ast.Index which we permit above.)
81+
slice_node = getattr(child, "slice", None)
82+
if isinstance(slice_node, ast.Index): # pre-3.9 compat
83+
slice_node = slice_node.value
84+
if not isinstance(slice_node, ast.Constant):
85+
raise UnsafePathError(
86+
"Subscript index must be a literal string or int"
87+
)
88+
if not isinstance(slice_node.value, (str, int)):
89+
raise UnsafePathError(
90+
f"Subscript index must be str or int, got {type(slice_node.value).__name__}"
91+
)
92+
if isinstance(child, ast.Name):
93+
if child.id not in _ALLOWED_ROOTS:
94+
raise UnsafePathError(
95+
f"Disallowed root identifier {child.id!r} — only {sorted(_ALLOWED_ROOTS)} are permitted"
96+
)
97+
98+
4499
def _eval_path(path: str):
45-
"""Evaluate a 'bpy.*' path safely (paths come from right-click ctx, trusted)."""
46-
return eval(path, {"bpy": bpy, "__builtins__": {}})
100+
"""Evaluate a stored 'bpy.*' path string into a runtime object.
101+
102+
Hardened against malicious .blend files that ship a poisoned
103+
``Studio.custom_paths[*].data_path`` aimed at executing arbitrary
104+
Python on Apply Studio. We parse the expression, walk the AST to
105+
confirm it's only attribute access + constant subscripts rooted in
106+
`bpy`, and only then evaluate. Anything more exotic raises
107+
UnsafePathError.
108+
"""
109+
try:
110+
tree = ast.parse(path, mode="eval")
111+
except SyntaxError as e:
112+
raise UnsafePathError(f"Invalid path syntax: {e}") from e
113+
_validate_ast(tree)
114+
# eval is safe here — we've proven the AST has no executable parts.
115+
# Empty __builtins__ is belt-and-braces; the AST validator already blocks
116+
# everything dangerous.
117+
return eval( # noqa: S307 — input is AST-validated above
118+
compile(tree, "<stage-data-path>", "eval"),
119+
{"bpy": bpy, "__builtins__": {}},
120+
)
47121

48122

49123
def resolve_value(path: str):
50-
"""Read the current value at `path`. Raises on bad path."""
124+
"""Read the current value at `path`. Raises on bad or unsafe path."""
51125
return _eval_path(path)
52126

53127

stage/queue/worker.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ def spawn_next() -> Optional[int]:
8484
# Try the next one (recursive — bounded by number of pending jobs)
8585
return spawn_next()
8686

87+
# Defensive: refuse blend paths that look like CLI flags or contain
88+
# NUL bytes. Even though Popen's list-form prevents argv injection,
89+
# Blender's own argv parser might mistake a `-`-prefixed positional
90+
# for a flag and re-route subsequent args (the .blend would be
91+
# treated as a flag and our render_script would become the .blend).
92+
# NUL bytes have caused execve weirdness on some platforms.
93+
if blend_path.startswith("-") or "\x00" in blend_path:
94+
with queue_db.connect() as conn:
95+
queue_db.mark_failed(
96+
conn, job_id,
97+
error_message=f"Refusing suspicious blend path: {blend_path!r}",
98+
)
99+
return spawn_next()
100+
87101
cmd = [
88102
bpy.app.binary_path,
89103
"-b", str(blend_path),

stage/utils/preview_cache.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
import bpy
1515
import bpy.utils.previews
1616

17+
from .logger import get_logger
18+
19+
20+
_log = get_logger()
21+
1722

1823
_pcoll = None
1924

@@ -52,8 +57,20 @@ def invalidate(uuid: str) -> None:
5257

5358

5459
def cleanup() -> None:
55-
"""Clear the preview collection. Called on addon unregister."""
60+
"""Clear the preview collection. Called on addon unregister.
61+
62+
`bpy.utils.previews.remove()` can raise on shutdown paths where the
63+
Blender preview manager has already torn itself down. Wrapping in
64+
try/except ensures the global state is consistently reset to None
65+
either way — otherwise an exception here would leave a dangling
66+
reference and trigger the ResourceWarning on next interpreter exit.
67+
"""
5668
global _pcoll
57-
if _pcoll is not None:
69+
if _pcoll is None:
70+
return
71+
try:
5872
bpy.utils.previews.remove(_pcoll)
73+
except Exception as e:
74+
_log.warning("preview_cache cleanup failed: %s: %s", type(e).__name__, e)
75+
finally:
5976
_pcoll = None

tests/blender/test_security.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Security regression tests for v0.1.1 hardening.
2+
3+
Covers:
4+
- store_property._eval_path AST allowlist (the eval -> AST-walker fix)
5+
- slack_webhook scheme + host validation
6+
- worker spawn_next blend-path leading-dash guard
7+
8+
These pin the security boundary so future refactors can't accidentally
9+
weaken it without a failing test.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from unittest import mock
15+
16+
import bpy
17+
18+
from stage.core.store_property import (
19+
UnsafePathError,
20+
_eval_path,
21+
resolve_value,
22+
)
23+
24+
25+
# --- AST-walker / _eval_path -----------------------------------------------
26+
27+
28+
def test_eval_path_accepts_simple_attribute_chain():
29+
# Real RNA path: should resolve without raising. Value identity isn't
30+
# important here — just that the walker permits the structure.
31+
val = _eval_path("bpy.context.scene")
32+
assert val is bpy.context.scene
33+
34+
35+
def test_eval_path_accepts_string_subscript():
36+
# bpy.data.scenes['Scene'] — the most common stored-path shape after
37+
# the right-click hook normalises into bpy.context.scene anyway.
38+
if "Scene" not in bpy.data.scenes:
39+
bpy.data.scenes.new("Scene")
40+
val = _eval_path("bpy.data.scenes['Scene']")
41+
assert val is bpy.data.scenes["Scene"]
42+
43+
44+
def test_eval_path_rejects_function_call():
45+
"""A poisoned data_path with a Call node must be refused."""
46+
raised = False
47+
try:
48+
_eval_path("bpy.ops.wm.quit_blender()")
49+
except UnsafePathError:
50+
raised = True
51+
assert raised, "Call nodes must raise UnsafePathError"
52+
53+
54+
def test_eval_path_rejects_lambda():
55+
raised = False
56+
try:
57+
_eval_path("(lambda: bpy.context.scene)()")
58+
except UnsafePathError:
59+
raised = True
60+
assert raised
61+
62+
63+
def test_eval_path_rejects_arithmetic():
64+
raised = False
65+
try:
66+
_eval_path("bpy.context.scene + bpy.context.scene")
67+
except UnsafePathError:
68+
raised = True
69+
assert raised
70+
71+
72+
def test_eval_path_rejects_dunder_access_via_non_bpy_root():
73+
"""A `__import__('os')...` attempt must fail because the root name
74+
isn't `bpy`."""
75+
raised = False
76+
try:
77+
_eval_path("__import__('os').system('echo pwned')")
78+
except UnsafePathError:
79+
raised = True
80+
assert raised
81+
82+
83+
def test_eval_path_rejects_non_constant_subscript():
84+
"""Dynamic subscripts (`a[b]` where b is a Name) must be refused —
85+
the indexer must be a literal."""
86+
raised = False
87+
try:
88+
_eval_path("bpy.data.scenes[bpy.context]")
89+
except UnsafePathError:
90+
raised = True
91+
assert raised
92+
93+
94+
def test_eval_path_rejects_syntax_error():
95+
raised = False
96+
try:
97+
_eval_path("bpy.context.scene.<<")
98+
except UnsafePathError:
99+
raised = True
100+
assert raised
101+
102+
103+
def test_resolve_value_propagates_unsafe_path_error():
104+
"""Public entry point must surface the same protection."""
105+
raised = False
106+
try:
107+
resolve_value("bpy.ops.wm.quit_blender()")
108+
except UnsafePathError:
109+
raised = True
110+
assert raised
111+
112+
113+
# --- slack_webhook scheme guard -------------------------------------------
114+
115+
116+
def _stub_action(target: str, message: str = ""):
117+
"""Minimal duck-typed stand-in for a PostRenderAction PropertyGroup."""
118+
class _A:
119+
pass
120+
a = _A()
121+
a.target = target
122+
a.message = message
123+
return a
124+
125+
126+
def test_slack_webhook_refuses_file_scheme():
127+
"""file:// URLs would let a poisoned .blend exfiltrate local files
128+
via urllib's default opener. Must be refused outright — no network
129+
call attempted."""
130+
from stage.core import post_render
131+
132+
scene = bpy.context.scene
133+
if scene.stage_data.studios:
134+
studio = scene.stage_data.studios[0]
135+
else:
136+
studio = scene.stage_data.studios.add()
137+
studio.name = "TestStudio"
138+
studio.uuid = "sec-test"
139+
140+
action = _stub_action("file:///etc/passwd")
141+
142+
with mock.patch.object(post_render.urlrequest, "urlopen") as fake_open:
143+
post_render.slack_webhook(action, scene, studio, "/tmp/out.png")
144+
assert fake_open.call_count == 0, (
145+
"urlopen must not be called for non-http(s) schemes"
146+
)
147+
148+
149+
def test_slack_webhook_refuses_gopher_scheme():
150+
from stage.core import post_render
151+
152+
scene = bpy.context.scene
153+
studio = scene.stage_data.studios[0] if scene.stage_data.studios else None
154+
if studio is None:
155+
studio = scene.stage_data.studios.add()
156+
studio.name = "TestStudio"
157+
studio.uuid = "sec-test"
158+
159+
action = _stub_action("gopher://internal-host:70/0/secret")
160+
161+
with mock.patch.object(post_render.urlrequest, "urlopen") as fake_open:
162+
post_render.slack_webhook(action, scene, studio, "/tmp/out.png")
163+
assert fake_open.call_count == 0
164+
165+
166+
def test_slack_webhook_refuses_url_with_no_host():
167+
from stage.core import post_render
168+
169+
scene = bpy.context.scene
170+
studio = scene.stage_data.studios[0] if scene.stage_data.studios else None
171+
if studio is None:
172+
studio = scene.stage_data.studios.add()
173+
studio.name = "TestStudio"
174+
studio.uuid = "sec-test"
175+
176+
action = _stub_action("https://")
177+
178+
with mock.patch.object(post_render.urlrequest, "urlopen") as fake_open:
179+
post_render.slack_webhook(action, scene, studio, "/tmp/out.png")
180+
assert fake_open.call_count == 0
181+
182+
183+
def test_slack_webhook_accepts_https_url():
184+
"""Sanity check the happy path — hooks.slack.com style URLs must\n still go through."""
185+
from stage.core import post_render
186+
187+
scene = bpy.context.scene
188+
studio = scene.stage_data.studios[0] if scene.stage_data.studios else None
189+
if studio is None:
190+
studio = scene.stage_data.studios.add()
191+
studio.name = "TestStudio"
192+
studio.uuid = "sec-test"
193+
194+
action = _stub_action("https://hooks.slack.com/services/T0/B0/XXX")
195+
196+
with mock.patch.object(post_render.urlrequest, "urlopen") as fake_open:
197+
post_render.slack_webhook(action, scene, studio, "/tmp/out.png")
198+
assert fake_open.call_count == 1, (
199+
"valid https URL should reach urlopen"
200+
)

0 commit comments

Comments
 (0)