From b4a78335e15924582ddb244fcdc761ed756cf3ee Mon Sep 17 00:00:00 2001 From: "odippel@ypsilon.net" Date: Tue, 18 Aug 2026 11:19:37 +0200 Subject: [PATCH 1/2] adding optional user pages / html files --- docs/src/man/man1/mtconnect-agent.1.adoc | 1 + lib/python/mtc/agent.py | 14 ++++++++++++++ lib/python/mtc/http_agent.py | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/docs/src/man/man1/mtconnect-agent.1.adoc b/docs/src/man/man1/mtconnect-agent.1.adoc index 22daa5fb126..8be084caaf3 100644 --- a/docs/src/man/man1/mtconnect-agent.1.adoc +++ b/docs/src/man/man1/mtconnect-agent.1.adoc @@ -64,6 +64,7 @@ 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 optional user pages / html files. *SHDR_PORT*:: Port for the SHDR adapter when *shdr* is in *TRANSPORT* (default 7878). SHDR feeds an external MTConnect agent (e.g. cppagent) `|id|value` lines; configure that agent with a Devices.xml from *--dump-probe*. diff --git a/lib/python/mtc/agent.py b/lib/python/mtc/agent.py index 16a17885518..bf0ad0bbd18 100644 --- a/lib/python/mtc/agent.py +++ b/lib/python/mtc/agent.py @@ -68,6 +68,10 @@ 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] != "/": + # use relativ path + self.user_pages = os.path.join(os.path.dirname(ini_path), self.user_pages) # -- data collection ----------------------------------------------------- @@ -112,6 +116,16 @@ 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)) + if path.startswith(self.user_pages) and os.path.isfile(path): + with open(path, "r") as fh: + return fh.read(), "text/html" + return None + def model_file(self, name): """Return (bytes, content_type) for a served mesh, or None.""" ref = self.models.served.get(name) diff --git a/lib/python/mtc/http_agent.py b/lib/python/mtc/http_agent.py index 0e5e8f2fea2..df1dc75fd54 100644 --- a/lib/python/mtc/http_agent.py +++ b/lib/python/mtc/http_agent.py @@ -96,6 +96,14 @@ def do_GET(self): else: data, content_type = result self._send(data, content_type=content_type) + elif route.startswith(f"/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) From 827d8d0f0ec751c42854e9790f33061588a84e74 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:48:12 +0800 Subject: [PATCH 2/2] mtconnect: harden user pages serving - Canonicalize USER_PAGES once and check containment with commonpath(); a plain startswith() prefix check is bypassable via a sibling directory sharing the name prefix (pages vs pages_evil). - Read files as bytes and pick the Content-Type with mimetypes; a text-mode read 500s on binary assets, and browsers refuse to run js/css served as text/html, so multi-file pages never loaded. - Document the /html/ route prefix in the man page. - Cover serving, content type, and traversal rejection in test_mtc.py. --- configs/sim/axis/mtconnect/test_mtc.py | 44 ++++++++++++++++++++++++ docs/src/man/man1/mtconnect-agent.1.adoc | 4 ++- lib/python/mtc/agent.py | 17 ++++++--- lib/python/mtc/http_agent.py | 2 +- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/configs/sim/axis/mtconnect/test_mtc.py b/configs/sim/axis/mtconnect/test_mtc.py index 4b15f3f5f60..005df9c6f3a 100644 --- a/configs/sim/axis/mtconnect/test_mtc.py +++ b/configs/sim/axis/mtconnect/test_mtc.py @@ -8,6 +8,7 @@ import socket import subprocess import sys +import tempfile import time import urllib.request import xml.etree.ElementTree as ET @@ -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("twin") + 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] == "-" @@ -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() diff --git a/docs/src/man/man1/mtconnect-agent.1.adoc b/docs/src/man/man1/mtconnect-agent.1.adoc index 8be084caaf3..c3ef1a5e2ff 100644 --- a/docs/src/man/man1/mtconnect-agent.1.adoc +++ b/docs/src/man/man1/mtconnect-agent.1.adoc @@ -64,7 +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 optional user pages / html files. +*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/*; 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) `|id|value` lines; configure that agent with a Devices.xml from *--dump-probe*. diff --git a/lib/python/mtc/agent.py b/lib/python/mtc/agent.py index bf0ad0bbd18..587e154c162 100644 --- a/lib/python/mtc/agent.py +++ b/lib/python/mtc/agent.py @@ -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 @@ -70,8 +71,11 @@ def __init__(self, ini_path, buffer_size=131072): self._lock = threading.Lock() self.user_pages = self.ini.find("MTCONNECT", "USER_PAGES", "") if self.user_pages and self.user_pages[0] != "/": - # use relativ path + # 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 ----------------------------------------------------- @@ -121,9 +125,14 @@ def user_page(self, name): if not self.user_pages: return None path = os.path.realpath(os.path.join(self.user_pages, name)) - if path.startswith(self.user_pages) and os.path.isfile(path): - with open(path, "r") as fh: - return fh.read(), "text/html" + # 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): diff --git a/lib/python/mtc/http_agent.py b/lib/python/mtc/http_agent.py index df1dc75fd54..926fba5c2d0 100644 --- a/lib/python/mtc/http_agent.py +++ b/lib/python/mtc/http_agent.py @@ -96,7 +96,7 @@ def do_GET(self): else: data, content_type = result self._send(data, content_type=content_type) - elif route.startswith(f"/html/"): + elif route.startswith("/html/"): result = agent.user_page(route[len("/html/"):]) if result is None: self._send(_error_document("NOT_FOUND",