@dpantani/tdmcp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -346
- package/dist/cli/agent.d.ts +139 -3
- package/dist/cli/agent.js +10236 -405
- package/dist/cli/agent.js.map +1 -1
- package/dist/index.js +13845 -2954
- package/dist/index.js.map +1 -1
- package/dist/knowledge/data/meta.json +1 -1
- package/dist/recipes/feedback_tunnel.json +4 -4
- package/dist/recipes/noise_landscape.json +102 -1
- package/dist/recipes/performable_feedback_tunnel.json +92 -0
- package/dist/recipes/webcam_glitch.json +5 -1
- package/package.json +21 -3
- package/recipes/feedback_tunnel.json +4 -4
- package/recipes/noise_landscape.json +102 -1
- package/recipes/performable_feedback_tunnel.json +92 -0
- package/recipes/webcam_glitch.json +5 -1
- package/td/README.md +1 -1
- package/td/modules/mcp/controllers/api_controller.py +70 -7
- package/td/modules/mcp/services/analysis_service.py +7 -2
- package/td/modules/mcp/services/api_service.py +25 -3
- package/td/modules/mcp/services/batch_service.py +36 -2
- package/td/modules/mcp/services/preview_service.py +89 -11
- package/td/modules/utils/version.py +1 -1
- package/td/tests/test_api_controller.py +67 -2
- package/td/tests/test_services.py +209 -0
|
@@ -16,6 +16,18 @@ from mcp import events
|
|
|
16
16
|
from mcp.services import analysis_service, api_service, batch_service, preview_service
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
class _Unauthorized(PermissionError):
|
|
20
|
+
"""Authentication failed — missing or invalid bearer token (HTTP 401)."""
|
|
21
|
+
|
|
22
|
+
status = 401
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Forbidden(PermissionError):
|
|
26
|
+
"""Request refused regardless of credentials — cross-origin or exec disabled (HTTP 403)."""
|
|
27
|
+
|
|
28
|
+
status = 403
|
|
29
|
+
|
|
30
|
+
|
|
19
31
|
def _required_token():
|
|
20
32
|
"""The shared bearer token the bridge enforces, or None when auth is off.
|
|
21
33
|
|
|
@@ -53,8 +65,14 @@ def _find_header(request, name):
|
|
|
53
65
|
if not isinstance(node, dict) or depth > 2:
|
|
54
66
|
return None
|
|
55
67
|
for key, value in node.items():
|
|
56
|
-
if isinstance(key, str) and key.lower() == target
|
|
57
|
-
|
|
68
|
+
if isinstance(key, str) and key.lower() == target:
|
|
69
|
+
# TD builds vary: a header may arrive as a str or as a list of
|
|
70
|
+
# strings (repeated header). Accept a list's first string element
|
|
71
|
+
# so the Origin guard never fails *open* on a list-shaped header.
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return value
|
|
74
|
+
if isinstance(value, (list, tuple)) and value and isinstance(value[0], str):
|
|
75
|
+
return value[0]
|
|
58
76
|
for nested in ("headers", "header", "fields"):
|
|
59
77
|
sub = node.get(nested)
|
|
60
78
|
hit = scan(sub, depth + 1) if isinstance(sub, dict) else None
|
|
@@ -71,7 +89,32 @@ def _check_auth(request):
|
|
|
71
89
|
return # auth disabled (default)
|
|
72
90
|
provided = (_find_header(request, "authorization") or "").strip()
|
|
73
91
|
if not hmac.compare_digest(provided, "Bearer " + token):
|
|
74
|
-
raise
|
|
92
|
+
raise _Unauthorized("Unauthorized: missing or invalid bearer token.")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_origin(request):
|
|
99
|
+
"""Reject browser-originated cross-origin requests (CSRF / DNS-rebinding).
|
|
100
|
+
|
|
101
|
+
The Node MCP server — the only legitimate caller — never sends an `Origin`
|
|
102
|
+
header. Browsers always attach one on cross-site requests, so a request
|
|
103
|
+
bearing a non-loopback `Origin` can only be a web page trying to drive the
|
|
104
|
+
bridge (e.g. a malicious site POSTing to http://127.0.0.1:9980/api/exec).
|
|
105
|
+
Under the default zero-auth + exec-on config that would be drive-by remote
|
|
106
|
+
code execution, so refuse it. Loopback origins stay allowed (a locally
|
|
107
|
+
served tool page still works) and same-origin / no-Origin callers are
|
|
108
|
+
unaffected. Holds even against a direct caller and independent of the
|
|
109
|
+
optional bearer token, mirroring the Node HTTP transport's DNS-rebinding
|
|
110
|
+
guard on its own port.
|
|
111
|
+
"""
|
|
112
|
+
origin = _find_header(request, "origin")
|
|
113
|
+
if not origin:
|
|
114
|
+
return
|
|
115
|
+
host = urlparse(origin).hostname
|
|
116
|
+
if host not in _LOOPBACK_HOSTS:
|
|
117
|
+
raise _Forbidden("Forbidden: cross-origin request rejected (origin %r)." % origin)
|
|
75
118
|
|
|
76
119
|
|
|
77
120
|
def _qs(query, key, default=None):
|
|
@@ -101,6 +144,20 @@ def _node_path(segments):
|
|
|
101
144
|
return "/" + raw.lstrip("/")
|
|
102
145
|
|
|
103
146
|
|
|
147
|
+
def _require(body, *keys):
|
|
148
|
+
"""Raise a descriptive error when a required body field is absent.
|
|
149
|
+
|
|
150
|
+
Without this, `body["script"]`/`body["type"]` etc. raise a bare KeyError whose
|
|
151
|
+
message is only the missing key name — opaque to a direct caller. The Node
|
|
152
|
+
client validates inputs with zod first, so this only fires for raw callers.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(body, dict):
|
|
155
|
+
raise ValueError("Request body must be a JSON object.")
|
|
156
|
+
missing = [k for k in keys if k not in body]
|
|
157
|
+
if missing:
|
|
158
|
+
raise ValueError("Missing required field(s): %s." % ", ".join(missing))
|
|
159
|
+
|
|
160
|
+
|
|
104
161
|
def _route(method, path, query, body):
|
|
105
162
|
parts = [p for p in path.split("/") if p]
|
|
106
163
|
if not parts or parts[0] != "api":
|
|
@@ -111,6 +168,7 @@ def _route(method, path, query, body):
|
|
|
111
168
|
return api_service.get_info()
|
|
112
169
|
|
|
113
170
|
if rest == ["nodes"] and method == "POST":
|
|
171
|
+
_require(body, "parent_path", "type")
|
|
114
172
|
return api_service.create_node(
|
|
115
173
|
body["parent_path"], body["type"], body.get("name"), body.get("parameters")
|
|
116
174
|
)
|
|
@@ -119,7 +177,8 @@ def _route(method, path, query, body):
|
|
|
119
177
|
|
|
120
178
|
if rest == ["exec"] and method == "POST":
|
|
121
179
|
if not _exec_allowed():
|
|
122
|
-
raise
|
|
180
|
+
raise _Forbidden("Forbidden: arbitrary code execution is disabled (TDMCP_BRIDGE_ALLOW_EXEC=0).")
|
|
181
|
+
_require(body, "script")
|
|
123
182
|
return api_service.exec_script(body["script"], body.get("return_output", True))
|
|
124
183
|
|
|
125
184
|
if rest == ["batch"] and method == "POST":
|
|
@@ -128,9 +187,10 @@ def _route(method, path, query, body):
|
|
|
128
187
|
if rest[0] == "nodes" and len(rest) >= 2:
|
|
129
188
|
if rest[-1] == "method" and method == "POST":
|
|
130
189
|
if not _exec_allowed():
|
|
131
|
-
raise
|
|
190
|
+
raise _Forbidden(
|
|
132
191
|
"Forbidden: arbitrary method calls are disabled (TDMCP_BRIDGE_ALLOW_EXEC=0)."
|
|
133
192
|
)
|
|
193
|
+
_require(body, "method")
|
|
134
194
|
return api_service.call_method(
|
|
135
195
|
_node_path(rest[1:-1]),
|
|
136
196
|
body["method"],
|
|
@@ -160,7 +220,9 @@ def _route(method, path, query, body):
|
|
|
160
220
|
if kind == "topology":
|
|
161
221
|
return analysis_service.topology(node_path, recursive=_qs(query, "recursive") == "true")
|
|
162
222
|
if kind == "performance":
|
|
163
|
-
return analysis_service.performance(
|
|
223
|
+
return analysis_service.performance(
|
|
224
|
+
node_path, recursive=_qs(query, "recursive") == "true"
|
|
225
|
+
)
|
|
164
226
|
|
|
165
227
|
raise ValueError("Unsupported %s %s" % (method, path))
|
|
166
228
|
|
|
@@ -226,6 +288,7 @@ def _emit_event(webserver, method, path, data):
|
|
|
226
288
|
|
|
227
289
|
def handle(request, response, webserver=None):
|
|
228
290
|
try:
|
|
291
|
+
_check_origin(request)
|
|
229
292
|
_check_auth(request)
|
|
230
293
|
method = (request.get("method") or "GET").upper()
|
|
231
294
|
parsed = urlparse(request.get("uri", "/"))
|
|
@@ -235,6 +298,6 @@ def handle(request, response, webserver=None):
|
|
|
235
298
|
_emit_event(webserver, method, parsed.path, data)
|
|
236
299
|
return _send(response, 200, {"ok": True, "data": data})
|
|
237
300
|
except PermissionError as exc:
|
|
238
|
-
return _send(response,
|
|
301
|
+
return _send(response, getattr(exc, "status", 403), {"ok": False, "error": {"message": str(exc)}})
|
|
239
302
|
except Exception as exc: # noqa: BLE001
|
|
240
303
|
return _send(response, 400, {"ok": False, "error": {"message": str(exc)}})
|
|
@@ -44,13 +44,18 @@ def topology(path, recursive=False):
|
|
|
44
44
|
return {"nodes": nodes, "connections": connections}
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def performance(path):
|
|
47
|
+
def performance(path, recursive=False):
|
|
48
48
|
root = op(path) # noqa: F821
|
|
49
49
|
if root is None:
|
|
50
50
|
return {"nodes": [], "total_cook_time_ms": 0.0}
|
|
51
51
|
nodes = []
|
|
52
52
|
total = 0.0
|
|
53
|
-
|
|
53
|
+
if not hasattr(root, "findChildren"):
|
|
54
|
+
children = []
|
|
55
|
+
elif recursive:
|
|
56
|
+
children = root.findChildren() # all descendants (cook time of nested nodes too)
|
|
57
|
+
else:
|
|
58
|
+
children = root.findChildren(depth=1) # direct children only
|
|
54
59
|
for child in children:
|
|
55
60
|
cook_time = float(getattr(child, "cookTime", 0.0) or 0.0)
|
|
56
61
|
total += cook_time
|
|
@@ -71,9 +71,15 @@ def create_node(parent_path, type_name, name=None, parameters=None):
|
|
|
71
71
|
raise LookupError("Parent not found: %s" % parent_path)
|
|
72
72
|
cls = _resolve_type(type_name)
|
|
73
73
|
node = parent.create(cls, name) if name else parent.create(cls)
|
|
74
|
+
ref = node_ref(node)
|
|
74
75
|
if parameters:
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
# The node is created regardless; surface any params that did not apply
|
|
77
|
+
# (unknown name or bad value) as a non-fatal warning rather than dropping
|
|
78
|
+
# them silently. The caller (create_td_node) relays these to the user.
|
|
79
|
+
_applied, failed = apply_parameters(node, parameters)
|
|
80
|
+
if failed:
|
|
81
|
+
ref["parameter_warnings"] = sorted(failed)
|
|
82
|
+
return ref
|
|
77
83
|
|
|
78
84
|
|
|
79
85
|
def delete_node(path):
|
|
@@ -120,7 +126,23 @@ def update_parameters(path, parameters):
|
|
|
120
126
|
node = op(path) # noqa: F821
|
|
121
127
|
if node is None:
|
|
122
128
|
raise LookupError("Node not found: %s" % path)
|
|
123
|
-
|
|
129
|
+
params = parameters or {}
|
|
130
|
+
# Reject unknown parameter names up front (atomic: apply nothing) so a typo
|
|
131
|
+
# like `gain` on a levelTOP fails loudly instead of being silently dropped.
|
|
132
|
+
unknown = [k for k in params if getattr(node.par, k, None) is None]
|
|
133
|
+
if unknown:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"Unknown parameter(s) on %s (%s): %s. "
|
|
136
|
+
"Use get_td_node_parameters to see the valid parameter names."
|
|
137
|
+
% (path, op_type(node), ", ".join(sorted(unknown)))
|
|
138
|
+
)
|
|
139
|
+
applied, failed = apply_parameters(node, params)
|
|
140
|
+
if failed:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"Could not set parameter(s) on %s (%s): %s "
|
|
143
|
+
"(wrong value type or out of range?). Applied: %s."
|
|
144
|
+
% (path, op_type(node), ", ".join(sorted(failed)), ", ".join(sorted(applied)) or "none")
|
|
145
|
+
)
|
|
124
146
|
return node_detail(node)
|
|
125
147
|
|
|
126
148
|
|
|
@@ -11,8 +11,42 @@ def connect(source_path, target_path, source_output=0, target_input=0):
|
|
|
11
11
|
src = op(source_path)
|
|
12
12
|
dst = op(target_path)
|
|
13
13
|
if src is None or dst is None:
|
|
14
|
-
raise LookupError("Source or target not found")
|
|
15
|
-
|
|
14
|
+
raise LookupError("Source or target not found: %s -> %s" % (source_path, target_path))
|
|
15
|
+
src_parent = src.parent()
|
|
16
|
+
dst_parent = dst.parent()
|
|
17
|
+
# TD wires only connect operators sharing a parent. A cross-container connect
|
|
18
|
+
# silently no-ops (no exception, no wire), so reject it with an actionable msg.
|
|
19
|
+
if src_parent is None or dst_parent is None or src_parent.path != dst_parent.path:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
"Cannot wire across containers: %s (in %s) -> %s (in %s). "
|
|
22
|
+
"Wires only connect operators sharing a parent; to bring an operator "
|
|
23
|
+
"across networks use a Select/In OP (e.g. a Select TOP/CHOP whose source "
|
|
24
|
+
"parameter points at %r)."
|
|
25
|
+
% (
|
|
26
|
+
src.path,
|
|
27
|
+
getattr(src_parent, "path", "<root>"),
|
|
28
|
+
dst.path,
|
|
29
|
+
getattr(dst_parent, "path", "<root>"),
|
|
30
|
+
source_path,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
in_conn = dst.inputConnectors[target_input]
|
|
35
|
+
out_conn = src.outputConnectors[source_output]
|
|
36
|
+
except IndexError:
|
|
37
|
+
raise IndexError(
|
|
38
|
+
"Connector index out of range: target_input=%d (%s has %d input(s)), "
|
|
39
|
+
"source_output=%d (%s has %d output(s))."
|
|
40
|
+
% (
|
|
41
|
+
target_input,
|
|
42
|
+
dst.path,
|
|
43
|
+
len(dst.inputConnectors),
|
|
44
|
+
source_output,
|
|
45
|
+
src.path,
|
|
46
|
+
len(src.outputConnectors),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
in_conn.connect(out_conn)
|
|
16
50
|
|
|
17
51
|
|
|
18
52
|
def run(operations):
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
"""Capture a TOP as a base64-encoded PNG.
|
|
1
|
+
"""Capture a TOP as a base64-encoded PNG.
|
|
2
|
+
|
|
3
|
+
The TOP is composited over a checkerboard first, so transparent regions read as a
|
|
4
|
+
checker pattern instead of solid white when a viewer flattens the PNG's alpha over a
|
|
5
|
+
white page. Opaque TOPs are unaffected (the checker stays fully hidden). If the
|
|
6
|
+
composite path errors for any reason, we fall back to saving the TOP directly.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
import base64
|
|
4
10
|
|
|
@@ -6,14 +12,18 @@ import td
|
|
|
6
12
|
|
|
7
13
|
op = td.op # TD globals are not available inside imported modules; reach via td
|
|
8
14
|
|
|
15
|
+
# Mid-gray checkerboard (16x9 cells). Self-contained fragment shader, no inputs/uniforms.
|
|
16
|
+
_CHECKER_FRAG = """out vec4 fragColor;
|
|
17
|
+
void main(){
|
|
18
|
+
vec2 cell = floor(vUV.st * vec2(16.0, 9.0));
|
|
19
|
+
float k = mod(cell.x + cell.y, 2.0);
|
|
20
|
+
fragColor = vec4(mix(vec3(0.16), vec3(0.30), k), 1.0);
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
9
23
|
|
|
10
|
-
def capture(path, width=640, height=360):
|
|
11
|
-
node = op(path)
|
|
12
|
-
if node is None:
|
|
13
|
-
raise LookupError("Node not found: %s" % path)
|
|
14
|
-
if getattr(node, "family", None) != "TOP":
|
|
15
|
-
raise ValueError("Preview is only supported for TOPs, got %s" % path)
|
|
16
24
|
|
|
25
|
+
def _save_png(node):
|
|
26
|
+
"""Return the node's PNG bytes, via saveByteArray with a temp-file fallback."""
|
|
17
27
|
data = None
|
|
18
28
|
try:
|
|
19
29
|
data = node.saveByteArray(".png")
|
|
@@ -21,7 +31,6 @@ def capture(path, width=640, height=360):
|
|
|
21
31
|
data = None
|
|
22
32
|
|
|
23
33
|
if data is None:
|
|
24
|
-
# Fallback: save to a temp file and read it back.
|
|
25
34
|
import os
|
|
26
35
|
import tempfile
|
|
27
36
|
|
|
@@ -30,11 +39,80 @@ def capture(path, width=640, height=360):
|
|
|
30
39
|
with open(tmp, "rb") as handle:
|
|
31
40
|
data = handle.read()
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
return bytes(data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _checkerboard_png(node, width, height):
|
|
46
|
+
"""Composite `node` over a checkerboard and return the flattened PNG bytes.
|
|
47
|
+
|
|
48
|
+
Creates a few temporary nodes in the node's parent and destroys them afterwards
|
|
49
|
+
(even on error). Returns None if the composite could not be produced, so callers
|
|
50
|
+
can fall back to a direct save.
|
|
51
|
+
"""
|
|
52
|
+
parent = node.parent()
|
|
53
|
+
if parent is None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
temps = []
|
|
57
|
+
try:
|
|
58
|
+
frag = parent.create("textDAT", "__tdmcp_pv_frag")
|
|
59
|
+
temps.append(frag)
|
|
60
|
+
frag.text = _CHECKER_FRAG
|
|
61
|
+
|
|
62
|
+
bg = parent.create("glslTOP", "__tdmcp_pv_bg")
|
|
63
|
+
temps.append(bg)
|
|
64
|
+
bg.par.pixeldat = frag.name
|
|
65
|
+
bg.par.outputresolution = "custom"
|
|
66
|
+
bg.par.resolutionw = width
|
|
67
|
+
bg.par.resolutionh = height
|
|
68
|
+
|
|
69
|
+
comp = parent.create("compositeTOP", "__tdmcp_pv_comp")
|
|
70
|
+
temps.append(comp)
|
|
71
|
+
comp.par.operand = "over"
|
|
72
|
+
comp.par.outputresolution = "custom"
|
|
73
|
+
comp.par.resolutionw = width
|
|
74
|
+
comp.par.resolutionh = height
|
|
75
|
+
comp.inputConnectors[0].connect(node) # foreground (over)
|
|
76
|
+
comp.inputConnectors[1].connect(bg) # background (under)
|
|
77
|
+
comp.cook(force=True)
|
|
78
|
+
|
|
79
|
+
if comp.errors():
|
|
80
|
+
return None
|
|
81
|
+
return _save_png(comp)
|
|
82
|
+
except Exception: # noqa: BLE001
|
|
83
|
+
return None
|
|
84
|
+
finally:
|
|
85
|
+
for t in reversed(temps):
|
|
86
|
+
try:
|
|
87
|
+
t.destroy()
|
|
88
|
+
except Exception: # noqa: BLE001
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def capture(path, width=640, height=360):
|
|
93
|
+
node = op(path)
|
|
94
|
+
if node is None:
|
|
95
|
+
raise LookupError("Node not found: %s" % path)
|
|
96
|
+
if getattr(node, "family", None) != "TOP":
|
|
97
|
+
raise ValueError("Preview is only supported for TOPs, got %s" % path)
|
|
98
|
+
|
|
99
|
+
w = int(getattr(node, "width", width) or width)
|
|
100
|
+
h = int(getattr(node, "height", height) or height)
|
|
101
|
+
# Clamp to a sane preview ceiling so a hostile/huge request (or a TOP with an
|
|
102
|
+
# extreme resolution) can't allocate a multi-gigapixel GPU texture and exhaust
|
|
103
|
+
# VRAM / hang TD. A preview is a thumbnail; 4096 on a side is plenty.
|
|
104
|
+
w = max(1, min(w, 4096))
|
|
105
|
+
h = max(1, min(h, 4096))
|
|
106
|
+
|
|
107
|
+
data = _checkerboard_png(node, w, h)
|
|
108
|
+
if data is None:
|
|
109
|
+
data = _save_png(node)
|
|
110
|
+
|
|
111
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
34
112
|
return {
|
|
35
113
|
"path": node.path,
|
|
36
|
-
"width":
|
|
37
|
-
"height":
|
|
114
|
+
"width": w,
|
|
115
|
+
"height": h,
|
|
38
116
|
"format": "png",
|
|
39
117
|
"base64": encoded,
|
|
40
118
|
}
|
|
@@ -179,6 +179,17 @@ class RoutingTests(unittest.TestCase):
|
|
|
179
179
|
with self.assertRaises(ValueError):
|
|
180
180
|
ac._route("GET", "/api/does-not-exist", {}, {})
|
|
181
181
|
|
|
182
|
+
def test_create_node_missing_fields_raises_descriptive(self):
|
|
183
|
+
with self.assertRaises(ValueError) as cm:
|
|
184
|
+
ac._route("POST", "/api/nodes", {}, {})
|
|
185
|
+
self.assertIn("parent_path", str(cm.exception))
|
|
186
|
+
self.assertIn("type", str(cm.exception))
|
|
187
|
+
|
|
188
|
+
def test_exec_missing_script_raises_descriptive(self):
|
|
189
|
+
with self.assertRaises(ValueError) as cm:
|
|
190
|
+
ac._route("POST", "/api/exec", {}, {})
|
|
191
|
+
self.assertIn("script", str(cm.exception))
|
|
192
|
+
|
|
182
193
|
|
|
183
194
|
class ParsingTests(unittest.TestCase):
|
|
184
195
|
def test_parse_body_variants(self):
|
|
@@ -201,6 +212,52 @@ class ParsingTests(unittest.TestCase):
|
|
|
201
212
|
self.assertEqual(ac._find_header({"X-Token": "abc"}, "x-token"), "abc")
|
|
202
213
|
self.assertIsNone(ac._find_header({"other": "v"}, "x-token"))
|
|
203
214
|
|
|
215
|
+
def test_find_header_accepts_list_value(self):
|
|
216
|
+
# A repeated/multi-value header may arrive as a list; take the first str.
|
|
217
|
+
self.assertEqual(ac._find_header({"Origin": ["http://x", "http://y"]}, "origin"), "http://x")
|
|
218
|
+
self.assertIsNone(ac._find_header({"Origin": []}, "origin"))
|
|
219
|
+
self.assertIsNone(ac._find_header({"Origin": [123]}, "origin"))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class OriginTests(unittest.TestCase):
|
|
223
|
+
"""The Origin guard blocks browser-driven CSRF / DNS-rebinding."""
|
|
224
|
+
|
|
225
|
+
def test_no_origin_is_allowed(self):
|
|
226
|
+
ac._check_origin({"method": "GET"}) # the Node client sends no Origin
|
|
227
|
+
|
|
228
|
+
def test_loopback_origins_allowed(self):
|
|
229
|
+
for origin in (
|
|
230
|
+
"http://127.0.0.1:9980",
|
|
231
|
+
"http://localhost",
|
|
232
|
+
"https://localhost:3000",
|
|
233
|
+
"http://[::1]:9980",
|
|
234
|
+
):
|
|
235
|
+
ac._check_origin({"Origin": origin}) # must not raise
|
|
236
|
+
|
|
237
|
+
def test_cross_origin_rejected(self):
|
|
238
|
+
for origin in ("http://evil.com", "https://attacker.example:8443", "http://192.168.1.5"):
|
|
239
|
+
with self.assertRaises(PermissionError, msg=origin):
|
|
240
|
+
ac._check_origin({"Origin": origin})
|
|
241
|
+
|
|
242
|
+
def test_opaque_null_origin_rejected(self):
|
|
243
|
+
with self.assertRaises(PermissionError):
|
|
244
|
+
ac._check_origin({"Origin": "null"})
|
|
245
|
+
|
|
246
|
+
def test_origin_lookup_is_case_insensitive_and_nested(self):
|
|
247
|
+
with self.assertRaises(PermissionError):
|
|
248
|
+
ac._check_origin({"headers": {"origin": "http://evil.com"}})
|
|
249
|
+
|
|
250
|
+
def test_handle_rejects_cross_origin_with_403(self):
|
|
251
|
+
resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": "http://evil.com"}, {})
|
|
252
|
+
self.assertEqual(resp["statusCode"], 403)
|
|
253
|
+
self.assertIn("cross-origin", resp["data"])
|
|
254
|
+
|
|
255
|
+
def test_handle_rejects_list_valued_cross_origin(self):
|
|
256
|
+
# Some TD builds surface a header as a list; it must not slip the guard.
|
|
257
|
+
resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": ["http://evil.com"]}, {})
|
|
258
|
+
self.assertEqual(resp["statusCode"], 403)
|
|
259
|
+
self.assertIn("cross-origin", resp["data"])
|
|
260
|
+
|
|
204
261
|
|
|
205
262
|
class HandleTests(unittest.TestCase):
|
|
206
263
|
def tearDown(self):
|
|
@@ -229,14 +286,22 @@ class HandleTests(unittest.TestCase):
|
|
|
229
286
|
self.assertEqual(resp["statusCode"], 400)
|
|
230
287
|
self.assertIn("boom", resp["data"])
|
|
231
288
|
|
|
232
|
-
def
|
|
289
|
+
def test_exec_disabled_surfaces_as_403_with_message(self):
|
|
233
290
|
os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
|
|
234
291
|
resp = ac.handle(
|
|
235
292
|
{"method": "POST", "uri": "/api/exec", "data": '{"script": "1"}'}, self._resp()
|
|
236
293
|
)
|
|
237
|
-
self.assertEqual(resp["statusCode"],
|
|
294
|
+
self.assertEqual(resp["statusCode"], 403)
|
|
238
295
|
self.assertIn("disabled", resp["data"])
|
|
239
296
|
|
|
297
|
+
def test_missing_required_field_surfaces_as_400_with_name(self):
|
|
298
|
+
# A POST with valid JSON but a missing field gets a descriptive 400,
|
|
299
|
+
# not a bare KeyError message of just the key name.
|
|
300
|
+
resp = ac.handle({"method": "POST", "uri": "/api/nodes", "data": "{}"}, self._resp())
|
|
301
|
+
self.assertEqual(resp["statusCode"], 400)
|
|
302
|
+
self.assertIn("parent_path", resp["data"])
|
|
303
|
+
self.assertIn("Missing required field", resp["data"])
|
|
304
|
+
|
|
240
305
|
|
|
241
306
|
if __name__ == "__main__":
|
|
242
307
|
unittest.main()
|