Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions configs/sim/axis/mtconnect/test_mtc.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
import xml.etree.ElementTree as ET
Expand Down Expand Up @@ -327,6 +328,48 @@ def get(path):
http.stop()


def test_user_pages():
state = AgentState(CONFIGS["3axis"])
root = tempfile.mkdtemp()
pages = os.path.join(root, "pages")
evil = os.path.join(root, "pages_evil") # sibling sharing the name prefix
os.mkdir(pages)
os.mkdir(evil)
with open(os.path.join(pages, "twin.html"), "w") as fh:
fh.write("<html><body>twin</body></html>")
with open(os.path.join(pages, "app.js"), "w") as fh:
fh.write("console.log(1)")
with open(os.path.join(evil, "e.html"), "w") as fh:
fh.write("outside")
state.user_pages = os.path.realpath(pages)

http = HttpAgent(state, host="127.0.0.1", port=0)
http.start()
try:
base = "http://127.0.0.1:%d" % http.port

def get(path):
with urllib.request.urlopen(base + path, timeout=5) as r:
return r.status, r.read(), r.headers.get("Content-Type")

st, body, ctype = get("/html/twin.html")
assert st == 200 and b"twin" in body and "text/html" in ctype, (st, ctype)
st, body, ctype = get("/html/app.js")
assert st == 200 and "javascript" in ctype, ctype

# Missing file, and escapes outside USER_PAGES: a ".." traversal and
# the sibling-prefix bypass (pages vs pages_evil) must both 404.
for path in ("/html/nope.html", "/html/../pages_evil/e.html"):
try:
get(path)
assert False, "expected 404 for " + path
except urllib.error.HTTPError as e:
assert e.code == 404, (path, e.code)
print("ok user pages (serve + content type + traversal rejected)")
finally:
http.stop()


def _is_iso_ts(field):
# e.g. 2026-07-28T12:00:00.000Z -- cheap structural check (no regex import).
return (len(field) >= 20 and field[4] == "-" and field[7] == "-"
Expand Down Expand Up @@ -625,6 +668,7 @@ def main():
test_schema_validation()
test_source_offline()
test_http_endpoints()
test_user_pages()
test_shdr()
test_hal_items()
test_solid_models()
Expand Down
3 changes: 3 additions & 0 deletions docs/src/man/man1/mtconnect-agent.1.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ All configuration is read from the *[MTCONNECT]* section:
*TRANSPORT*:: Comma-separated list of *http*, *mqtt*, *shdr* (default *http*).
*HTTP_PORT*:: Embedded HTTP agent port (default 5000).
*HTTP_BIND*:: Interface to bind (default 127.0.0.1; use 0.0.0.0 for the LAN).
*USER_PAGES*:: Path to a directory with optional user pages / html files.
A relative path is resolved against the ini file's directory. Files are
served under */html/<name>*; only files inside this directory are reachable.
*SHDR_PORT*:: Port for the SHDR adapter when *shdr* is in *TRANSPORT* (default
7878). SHDR feeds an external MTConnect agent (e.g. cppagent) `<ts>|id|value`
lines; configure that agent with a Devices.xml from *--dump-probe*.
Expand Down
23 changes: 23 additions & 0 deletions lib/python/mtc/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
# source, and produces the four MTConnect documents. Transport-agnostic so it
# can be driven by the embedded HTTP server (http_agent) or an MQTT publisher.

import mimetypes
import os
import threading
from datetime import datetime, timezone
Expand Down Expand Up @@ -68,6 +69,13 @@ def __init__(self, ini_path, buffer_size=131072):
self._last_in_spindle = None
self._last_values = {}
self._lock = threading.Lock()
self.user_pages = self.ini.find("MTCONNECT", "USER_PAGES", "")
if self.user_pages and self.user_pages[0] != "/":
# relative to the ini file's directory
self.user_pages = os.path.join(os.path.dirname(ini_path), self.user_pages)
if self.user_pages:
# canonical form, so containment checks in user_page() compare like with like
self.user_pages = os.path.realpath(self.user_pages)

# -- data collection -----------------------------------------------------

Expand Down Expand Up @@ -112,6 +120,21 @@ def extension_schema(self):
with open(path, "rb") as fh:
return fh.read(), "application/xml"

def user_page(self, name):
"""Return (bytes, content_type) for a served user file, or None."""
if not self.user_pages:
return None
path = os.path.realpath(os.path.join(self.user_pages, name))
# a plain startswith() prefix check is bypassable via a sibling
# directory whose name shares the prefix ("pages" vs "pages_evil")
if os.path.commonpath((self.user_pages, path)) != self.user_pages:
return None
if os.path.isfile(path):
ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
with open(path, "rb") as fh:
return fh.read(), ctype
return None

def model_file(self, name):
"""Return (bytes, content_type) for a served mesh, or None."""
ref = self.models.served.get(name)
Expand Down
8 changes: 8 additions & 0 deletions lib/python/mtc/http_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ def do_GET(self):
else:
data, content_type = result
self._send(data, content_type=content_type)
elif route.startswith("/html/"):
result = agent.user_page(route[len("/html/"):])
if result is None:
self._send(_error_document("NOT_FOUND",
"no such file: %s" % route), status=404)
else:
data, content_type = result
self._send(data, content_type=content_type)
else:
self._send(_error_document("UNSUPPORTED",
"unsupported request: %s" % route), status=404)
Expand Down