From 7633ee0d84443c4f1748f43fa318c8eae2b3222f Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 10 Sep 2026 20:22:41 +0000 Subject: [PATCH] onlyoffice: bind temp file server to bridge IP, silence handler tracebacks, self-limit lifetime The temporary HTTP server that OnlyOffice downloads the source document from was bound to 0.0.0.0, so internet scanners could (and did) connect, and socketserver dumped a full traceback to stderr for every dropped connection. It also stayed up for the whole conversion attempt, leaving the port exposed when conversions hang. - Bind only to the callback host (oonet gateway by default) so the port is unreachable from the internet. - Override handle_error to log at debug level instead of printing tracebacks. - Watchdog shuts the server down ~2s after the file is fetched, or at max_lifetime (request_timeout + 30s), and the socket is closed with server_close() in the normal path. --- mediapreview/office.py | 56 +++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/mediapreview/office.py b/mediapreview/office.py index b77988c..38a8aed 100644 --- a/mediapreview/office.py +++ b/mediapreview/office.py @@ -283,32 +283,65 @@ async def is_available_cached() -> bool: # --------------------------------------------------------------------------- +class _TempServer(socketserver.TCPServer): + """TCPServer that logs handler errors instead of dumping tracebacks to stderr.""" + + daemon_threads = True + oo_fetched: bool + + def handle_error(self, request, client_address) -> None: # noqa: ARG002 + # Dropped connections (client disconnects mid-request, port scanners) + # are routine noise; socketserver's default prints a full traceback. + logger.debug("Temp file server: error from %s", client_address) + + class _QuietHandler(SimpleHTTPRequestHandler): + server: _TempServer + def log_message(self, fmt, *args) -> None: # Any request logged here means a client (OnlyOffice) connected to # fetch the file; record it for timeout diagnostics. self.server.oo_fetched = True -def _get_free_port() -> int: +def _get_free_port(host: str) -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("0.0.0.0", 0)) # noqa: S104 + s.bind((host, 0)) return s.getsockname()[1] -def _serve_file_temporarily(file_path: Path): - """Start a temporary HTTP server for *file_path* and return (url, server).""" +def _serve_file_temporarily(file_path: Path, max_lifetime: float = 60.0): + """Start a temporary HTTP server for *file_path* and return (url, server). + + The server binds only to the callback host address (the docker bridge + gateway by default), not 0.0.0.0, so it is unreachable from the internet. + It shuts itself down shortly after the file has been fetched, or when + *max_lifetime* elapses, so a hung OnlyOffice request cannot leave the + port open indefinitely. + """ directory = str(file_path.parent) filename = file_path.name - port = _get_free_port() + host = _get_callback_host() + port = _get_free_port(host) handler = partial(_QuietHandler, directory=directory) - httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104 + httpd = _TempServer((host, port), handler) httpd.oo_fetched = False thread = threading.Thread(target=httpd.serve_forever, daemon=True) thread.start() - host = _get_callback_host() + def _watchdog() -> None: + deadline = perf_counter() + max_lifetime + while perf_counter() < deadline and not httpd.oo_fetched: + threading.Event().wait(0.1) + if httpd.oo_fetched: + # Brief grace so the in-flight response finishes transferring. + threading.Event().wait(2.0) + httpd.shutdown() + httpd.server_close() + + threading.Thread(target=_watchdog, daemon=True).start() + url = f"http://{host}:{port}/{quote(filename)}" return url, httpd @@ -343,8 +376,12 @@ async def convert_to_png_async( convert_url = f"{oo_url}/ConvertService.ashx" client = get_httpx_client() - # Start temporary HTTP server so OnlyOffice can fetch the file - doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path) + # Start temporary HTTP server so OnlyOffice can fetch the file. The + # watchdog lifetime covers the full conversion plus slack so a hung + # conversion cannot leave the port open forever. + doc_url, httpd = await asyncio.to_thread( + _serve_file_temporarily, file_path, request_timeout + 30.0 + ) try: suffix = file_path.suffix.lstrip(".").lower() payload = { @@ -415,6 +452,7 @@ async def convert_to_png_async( return png_response.content finally: await asyncio.to_thread(httpd.shutdown) + await asyncio.to_thread(httpd.server_close) # ---------------------------------------------------------------------------