Skip to content
Merged
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
8 changes: 1 addition & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.12.10-slim-bookworm
FROM python:3.12-slim-bookworm

ENV PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
Expand All @@ -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 .
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 3 additions & 7 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
bleach==6.3.0
fastapi==0.135.3
pydantic==2.12.5
fastapi==0.139.0
python-dotenv==1.2.2
Requests==2.33.1
starlette==1.0.0
telnyx==4.105.0
uvicorn==0.44.0
telnyx==4.167.0
uvicorn==0.50.0
pynacl==1.6.2
115 changes: 47 additions & 68 deletions server.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -55,7 +53,8 @@
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')
Expand All @@ -74,10 +73,6 @@
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}")
Expand Down Expand Up @@ -110,6 +105,13 @@
name = name.lstrip('.')
return name or 'file'

def safe_path(directory: str, file_name: str) -> str:
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

# SSRF guard for media URLs
def is_safe_media_url(url: str) -> bool:
try:
Expand All @@ -134,47 +136,35 @@
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)
file_name = f"SMS_from_{secure_filename(from_number)}_at_{timestamp}.txt"
#Store SMS In as plain text
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)
file_path = os.path.join(directory, file_name)
with open(file_path, "w") as file:
file.write(sanitized_message)
with open(safe_path(directory, file_name), "w") as file:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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'):
if not is_safe_media_url(url):
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()
file_name = f"Fax_{secure_filename(os.path.basename(urlparse(url).path)[:5])}_from_{secure_filename(from_number)}_at_{timestamp}.pdf"
# redirects rejected; HTTPError raised on bad status
with _url_opener.open(url, timeout=30) as r:
content = r.read()
# 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:
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}")
Expand All @@ -192,17 +182,17 @@
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} ({len(message)} chars)")
Comment thread
msmhome marked this conversation as resolved.
Dismissed
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:
Expand All @@ -229,7 +219,8 @@

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)
Expand Down Expand Up @@ -300,8 +291,6 @@
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)}")
Expand All @@ -313,27 +302,17 @@
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_{timestamp}_confirmed.pdf"
new_file_path = os.path.join('Faxes', 'outbound_confirmations', new_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:
# 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")
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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
os.remove(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(
Expand Down
Loading