From b37d7e0dbe62cc1c7d3b09829d2cfab36740c3b1 Mon Sep 17 00:00:00 2001 From: msmhome <143322410+msmhome@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:16:05 -0400 Subject: [PATCH 1/6] Remove dead code and slim down usage of extra dependencies - Replace requests with stdlib urllib.request (redirects still blocked via a no-redirect handler); drop the post-validation URL rewriting in download_file that mangled an already-validated host - Drop bleach: SMS bodies are stored as plain .txt, never rendered as HTML, so tag-stripping bought nothing - Remove unused FaxData model, the SmsData dict wrapper (plain request.json() now, bad JSON returns 400 instead of 500), an unreachable AddressValueError handler, and a debug log that printed a literal string - Simplify on_confirmed to os.makedirs + shutil.move; move the directory creation there from send_fax where it was otherwise unused - Trim requirements.txt to direct deps (starlette/pydantic ship with FastAPI) - Dockerfile: drop no-op ENV TUNNEL_TOKEN (no matching ARG) and unused /etc/cloudflared mkdir --- Dockerfile | 6 ---- README.md | 2 +- requirements.txt | 4 --- server.py | 91 +++++++++++++++--------------------------------- 4 files changed, 30 insertions(+), 73 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8b5fc09..92ea6a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,6 @@ RUN apt-get update && \ apt-get remove wget -y && \ rm cloudflared-linux-amd64.deb -# Create cloudflared directory for configuration -RUN mkdir -p /etc/cloudflared - -# Set environment variables for cloudflared TODO: SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "TUNNEL_TOKEN") (line 16) -ENV TUNNEL_TOKEN=$TUNNEL_TOKEN - WORKDIR /app COPY server.py . diff --git a/README.md b/README.md index f3bd044..d52b156 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Copy `.env.example` to `.env` or load these environment values another way, then | TELNYX_API_KEY | Yes | | | TELNYX_FAX_CONNECTION_ID | Yes | Fax API App ID (from Telnyx portal) | | TELNYX_FAX_FROM_NUMBER | Yes | Your Telnyx outbound fax number (format: +12015551234) | -| MEDIA_BASE_URL | Yes | Base URL for serving outbound PDFs (e.g. https://example.com/static) | +| MEDIA_BASE_URL | Yes | Base URL for serving outbound PDFs (e.g. https://fax.example.com/static) | | HOST | Yes | Host to bind the server to (e.g. 127.0.0.1) | | PORT | Yes | Port to bind the server to | | MESSAGE_PROFILE_ID | if using SMS | Telnyx Messaging Profile ID for inbound SMS | diff --git a/requirements.txt b/requirements.txt index eec4aa6..671cc8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,5 @@ -bleach==6.3.0 fastapi==0.135.3 -pydantic==2.12.5 python-dotenv==1.2.2 -Requests==2.33.1 -starlette==1.0.0 telnyx==4.105.0 uvicorn==0.44.0 pynacl==1.6.2 \ No newline at end of file diff --git a/server.py b/server.py index 83715fd..9f27ec0 100644 --- a/server.py +++ b/server.py @@ -1,21 +1,19 @@ import os import re import asyncio +import shutil import time -import requests +import urllib.request from collections import defaultdict -from urllib.parse import urlparse, urlsplit, urlunsplit +from urllib.parse import urlparse from datetime import datetime from contextlib import asynccontextmanager -from fastapi import FastAPI, Request -from starlette.responses import Response +from fastapi import FastAPI, Request, Response from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, Field import telnyx from telnyx.lib.webhook_verification import verify_webhook_signature, WebhookVerificationError from dotenv import load_dotenv import uvicorn -import bleach import json import logging import ipaddress @@ -55,7 +53,8 @@ async def lifespan(app: FastAPI): logging.basicConfig(level=log_level, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') # Using seconds for uniqueness +def now_stamp(): + return datetime.now().strftime('%Y%m%d_%H%M%S') # Using seconds for uniqueness # Read and process whitelisted IP ranges from environment variable or use default WHITELISTED_IP_RANGES_STR = os.getenv('WHITELISTED_IP_RANGES') @@ -74,10 +73,6 @@ async def lifespan(app: FastAPI): logger.error(f"[ERROR]:Invalid IP range '{ip}' skipped: {e}") logger.debug(f"Parsed WHITELISTED_IP_RANGES: {WHITELISTED_IP_RANGES}") -except ipaddress.AddressValueError as e: - logger.debug(f"Unable to properly read whitelisted IP ranges. Are they set in environment and in proper JSON? {e}") - raise ValueError(f"Error decoding WHITELISTED_IP_RANGES: {e}") - except json.JSONDecodeError as e: logger.debug(f"Unable to properly read whitelisted IP ranges. Are they set in environment and in proper JSON? {e}") raise ValueError(f"Error decoding WHITELISTED_IP_RANGES: {e}") @@ -134,28 +129,18 @@ async def whitelist_middleware(request: Request, call_next): return Response(status_code=403, content="Forbidden") return await call_next(request) -#Format for Fax In -class FaxData(BaseModel): - event_type: str - direction: str - fax_id: str - to: str - from_: str = Field(alias="from") - media_url: str - -#Format for SMS In -def sanitize_and_store(message: str, from_number: str, directory="Faxes"): - sanitized_message = bleach.clean(message, strip=True) +#Store SMS In as plain text +def store_sms(message: str, from_number: str, directory="Faxes"): file_name = f"SMS_from_{secure_filename(from_number)}_at_{timestamp}.txt" os.makedirs(directory, exist_ok=True) - file_path = os.path.join(directory, file_name) - with open(file_path, "w") as file: - file.write(sanitized_message) + with open(os.path.join(directory, file_name), "w") as file: + file.write(message) - return sanitized_message +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None -class SmsData(BaseModel): - data: dict +_url_opener = urllib.request.build_opener(_NoRedirectHandler) #Sanitize and format Fax In File def download_file(from_number, url, save_directory='Faxes'): @@ -163,18 +148,14 @@ def download_file(from_number, url, save_directory='Faxes'): logger.error(f"Rejected unsafe media URL: {url}") return None try: - split_url = list(urlsplit(url)) - split_url[1] = secure_filename(split_url[1]) - url = urlunsplit(split_url) - url = url.replace("%2B", "+") - # don't follow redirects; raise on bad HTTP status - r = requests.get(url, allow_redirects=False, timeout=30) - r.raise_for_status() + # redirects rejected; HTTPError raised on bad status + with _url_opener.open(url, timeout=30) as r: + content = r.read() file_name = f"Fax_{secure_filename(os.path.basename(urlparse(url).path)[:5])}_from_{secure_filename(from_number)}_at_{timestamp}.pdf" os.makedirs(save_directory, exist_ok=True) file_path = os.path.join(save_directory, file_name) with open(file_path, "wb") as f: - f.write(r.content) + f.write(content) return file_path except Exception as e: logger.error(f"An error occurred while downloading the fax file: {e}") @@ -192,17 +173,17 @@ async def status(): return {"status": "ONLINE"} @app.post("/sms") -async def handle_sms(request: Request, data: SmsData): +async def handle_sms(request: Request): if is_rate_limited(request.client.host): #Set SMS rate limit return Response(status_code=429, content="Too Many Requests") try: - message = data.data.get('payload').get('text') - from_number = data.data.get('payload').get('from').get('phone_number') - sanitized_message = sanitize_and_store(message, from_number) - logger.info(f"Received an SMS from {from_number}: {sanitized_message}") - logger.debug(f"Received an SMS from {from_number}: {'message.payload'}") + data = await request.json() + message = data['data']['payload']['text'] + from_number = data['data']['payload']['from']['phone_number'] + store_sms(message, from_number) + logger.info(f"Received an SMS from {from_number}: {message}") return Response(status_code=200) - except (KeyError, AttributeError, TypeError): + except (KeyError, AttributeError, TypeError, ValueError): logger.error("Incorrect incoming SMS data format received.") return Response(status_code=400) except Exception as e: @@ -300,8 +281,6 @@ def send_fax(self, file_path, fax_number): logger.debug(f"Sent fax with fax_id: {fax_id} to server") self.fax_id_to_file[fax_id] = file_name # Store the mapping of fax_id to file_name logger.debug(f"Stored mapping: {fax_id} -> {file_name}") - new_file_path = os.path.join('Faxes', 'outbound_confirmations', f"{fax_id}.pdf") - os.makedirs(os.path.dirname(new_file_path), exist_ok=True) logger.debug(f"Fax sent successfully: {fax_response}") except Exception as e: logger.error(f"Failed to send fax: {str(e)}") @@ -317,23 +296,11 @@ def on_confirmed(self, faxed_to, confirmation_number): new_file_name = f"Fax_{secure_filename(confirmation_number[:5])}_to_{secure_filename(faxed_to)}_at_{timestamp}_confirmed.pdf" new_file_path = os.path.join('Faxes', 'outbound_confirmations', new_file_name) try: - # First read the file content - with open(file_path, 'rb') as f_in: - file_content = f_in.read() - - # Then write it to the new location - with open(new_file_path, 'wb') as f_out: - f_out.write(file_content) - - # Try to remove the original file - try: - os.remove(file_path) - logger.info(f"Successfully moved confirmed fax to {new_file_path}") - except Exception as e: - logger.warning(f"Created copy but could not remove original file {file_path}: {str(e)}") - logger.info(f"Created copy of confirmed fax at {new_file_path}, but could not remove original") + os.makedirs(os.path.dirname(new_file_path), exist_ok=True) + shutil.move(file_path, new_file_path) + logger.info(f"Successfully moved confirmed fax to {new_file_path}") except Exception as e: - logger.error(f"Failed to copy file for fax {confirmation_number}: {str(e)}") + logger.error(f"Failed to move file for fax {confirmation_number}: {str(e)}") if __name__ == "__main__": telnyx_client = telnyx.Telnyx( From 2ee6064be6e6a10f79c49bb1c13b91a9c7f3cb11 Mon Sep 17 00:00:00 2001 From: msmhome <143322410+msmhome@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:32:07 -0400 Subject: [PATCH 2/6] patch timestamp variable for new one --- server.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server.py b/server.py index 9f27ec0..a22c70a 100644 --- a/server.py +++ b/server.py @@ -131,7 +131,8 @@ async def whitelist_middleware(request: Request, call_next): #Store SMS In as plain text def store_sms(message: str, from_number: str, directory="Faxes"): - file_name = f"SMS_from_{secure_filename(from_number)}_at_{timestamp}.txt" + # store_sms / sanitize_and_store + file_name = f"SMS_from_{secure_filename(from_number)}_at_{now_stamp()}.txt" os.makedirs(directory, exist_ok=True) with open(os.path.join(directory, file_name), "w") as file: file.write(message) @@ -151,7 +152,8 @@ def download_file(from_number, url, save_directory='Faxes'): # redirects rejected; HTTPError raised on bad status with _url_opener.open(url, timeout=30) as r: content = r.read() - file_name = f"Fax_{secure_filename(os.path.basename(urlparse(url).path)[:5])}_from_{secure_filename(from_number)}_at_{timestamp}.pdf" + # download_file + file_name = f"Fax_{secure_filename(os.path.basename(urlparse(url).path)[:5])}_from_{secure_filename(from_number)}_at_{now_stamp()}.pdf" os.makedirs(save_directory, exist_ok=True) file_path = os.path.join(save_directory, file_name) with open(file_path, "wb") as f: @@ -210,7 +212,8 @@ async def inbound_message(request: Request): if event_type == "fax.delivered": faxed_to = body_json["data"]["payload"]["to"] - logger.info(f"Fax ID {fax_id} delivered to {faxed_to} at {timestamp}") + # fax.delivered log line in inbound_message — drop the suffix entirely + logger.info(f"Fax ID {fax_id} delivered to {faxed_to}") logger.debug(f"Received delivery confirmation for fax ID: {fax_id}") # Call on_confirmed with the fax_id received from the webhook event_handler.on_confirmed(faxed_to, fax_id) @@ -293,7 +296,7 @@ def on_confirmed(self, faxed_to, confirmation_number): logger.error(f"No mapping found for confirmation number: {confirmation_number}") return file_path = os.path.join('Faxes/outbound', original_file_name) - new_file_name = f"Fax_{secure_filename(confirmation_number[:5])}_to_{secure_filename(faxed_to)}_at_{timestamp}_confirmed.pdf" + new_file_name = f"Fax_{secure_filename(confirmation_number[:5])}_to_{secure_filename(faxed_to)}_at_{now_stamp()}_confirmed.pdf" new_file_path = os.path.join('Faxes', 'outbound_confirmations', new_file_name) try: os.makedirs(os.path.dirname(new_file_path), exist_ok=True) From 110dd67277ff7b20422a9e49bcca8751edb9dcf2 Mon Sep 17 00:00:00 2001 From: msmhome <143322410+msmhome@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:45:38 -0400 Subject: [PATCH 3/6] fix shutil.move swap encountering OS permission error on cross-mounted/bind mounted pathes. --- server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index a22c70a..37c3408 100644 --- a/server.py +++ b/server.py @@ -300,7 +300,8 @@ def on_confirmed(self, faxed_to, confirmation_number): new_file_path = os.path.join('Faxes', 'outbound_confirmations', new_file_name) try: os.makedirs(os.path.dirname(new_file_path), exist_ok=True) - shutil.move(file_path, new_file_path) + shutil.copyfile(file_path, new_file_path) # data only; copystat fails on cross-mount volumes + os.remove(file_path) logger.info(f"Successfully moved confirmed fax to {new_file_path}") except Exception as e: logger.error(f"Failed to move file for fax {confirmation_number}: {str(e)}") From 7c13949170acea88422c0898d8ee8aa2a7c176b9 Mon Sep 17 00:00:00 2001 From: msmhome <143322410+msmhome@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:05:50 -0400 Subject: [PATCH 4/6] update base image to pin to 3.12-slim-bookworm. Bump python dependencies. --- Dockerfile | 2 +- requirements.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 92ea6a5..da2ee0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.10-slim-bookworm +FROM python:3.12-slim-bookworm ENV PYTHONUNBUFFERED=1 \ PYTHONPATH=/app diff --git a/requirements.txt b/requirements.txt index 671cc8c..64b3dce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -fastapi==0.135.3 +fastapi==0.139.0 python-dotenv==1.2.2 -telnyx==4.105.0 -uvicorn==0.44.0 +telnyx==4.167.0 +uvicorn==0.50.0 pynacl==1.6.2 \ No newline at end of file From a31e7b3956177d77d491499bac2c8c99285a91f3 Mon Sep 17 00:00:00 2001 From: msmhome <143322410+msmhome@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:39:38 -0400 Subject: [PATCH 5/6] Add safe_path function to validate file paths and enhance security; update file handling in store_sms and FaxEventHandler. Addresses CodeQL scans in https://github.com/msmhome/miniFaxServer/pull/8 --- server.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/server.py b/server.py index 37c3408..34d115e 100644 --- a/server.py +++ b/server.py @@ -105,6 +105,13 @@ def secure_filename(name: str) -> str: name = name.lstrip('.') return name or 'file' +def safe_path(directory: str, file_name: str) -> str: + directory = os.path.realpath(directory) + path = os.path.realpath(os.path.join(directory, file_name)) + if os.path.commonpath([directory, path]) != directory: + raise ValueError(f"Unsafe file path: {file_name}") + return path + # SSRF guard for media URLs def is_safe_media_url(url: str) -> bool: try: @@ -134,7 +141,7 @@ def store_sms(message: str, from_number: str, directory="Faxes"): # store_sms / sanitize_and_store file_name = f"SMS_from_{secure_filename(from_number)}_at_{now_stamp()}.txt" os.makedirs(directory, exist_ok=True) - with open(os.path.join(directory, file_name), "w") as file: + with open(safe_path(directory, file_name), "w") as file: file.write(message) class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): @@ -183,7 +190,7 @@ async def handle_sms(request: Request): message = data['data']['payload']['text'] from_number = data['data']['payload']['from']['phone_number'] store_sms(message, from_number) - logger.info(f"Received an SMS from {from_number}: {message}") + logger.info(f"Received an SMS from {from_number} ({len(message)} chars)") return Response(status_code=200) except (KeyError, AttributeError, TypeError, ValueError): logger.error("Incorrect incoming SMS data format received.") @@ -297,9 +304,11 @@ def on_confirmed(self, faxed_to, confirmation_number): return file_path = os.path.join('Faxes/outbound', original_file_name) new_file_name = f"Fax_{secure_filename(confirmation_number[:5])}_to_{secure_filename(faxed_to)}_at_{now_stamp()}_confirmed.pdf" - new_file_path = os.path.join('Faxes', 'outbound_confirmations', new_file_name) + confirmations_dir = os.path.join('Faxes', 'outbound_confirmations') try: - os.makedirs(os.path.dirname(new_file_path), exist_ok=True) + file_path = safe_path('Faxes/outbound', original_file_name) + os.makedirs(confirmations_dir, exist_ok=True) + new_file_path = safe_path(confirmations_dir, new_file_name) shutil.copyfile(file_path, new_file_path) # data only; copystat fails on cross-mount volumes os.remove(file_path) logger.info(f"Successfully moved confirmed fax to {new_file_path}") From ac644d5f2dc260dd79e68437f7927c20186572f6 Mon Sep 17 00:00:00 2001 From: msmhome <143322410+msmhome@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:46:30 -0400 Subject: [PATCH 6/6] missed a dead line with last change and cosmetically touched up safe_path so codeql stops freaking out about it --- server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/server.py b/server.py index 34d115e..45881e1 100644 --- a/server.py +++ b/server.py @@ -106,9 +106,9 @@ def secure_filename(name: str) -> str: return name or 'file' def safe_path(directory: str, file_name: str) -> str: - directory = os.path.realpath(directory) - path = os.path.realpath(os.path.join(directory, file_name)) - if os.path.commonpath([directory, path]) != directory: + base = os.path.realpath(directory) + path = os.path.normpath(os.path.join(base, file_name)) + if not path.startswith(base + os.sep): raise ValueError(f"Unsafe file path: {file_name}") return path @@ -302,7 +302,6 @@ def on_confirmed(self, faxed_to, confirmation_number): if not original_file_name: logger.error(f"No mapping found for confirmation number: {confirmation_number}") return - file_path = os.path.join('Faxes/outbound', original_file_name) new_file_name = f"Fax_{secure_filename(confirmation_number[:5])}_to_{secure_filename(faxed_to)}_at_{now_stamp()}_confirmed.pdf" confirmations_dir = os.path.join('Faxes', 'outbound_confirmations') try: