diff --git a/main.py b/main.py index 77c6321..fda1649 100644 --- a/main.py +++ b/main.py @@ -4,29 +4,28 @@ Standalone HTTP file server extracted from WiFi-Home-Server. Hosts local files/folders over WiFi so any device on the same network can browse and download them through a browser. - No ADB, no USB, no app needed on the Android side. -Android just opens: http://:8765 +Android just opens: http://:8765 How it works: - 1. get_local_ip() — finds this PC's WiFi IP - 2. build_index_html() — generates the root HTML listing all shared items - 3. build_dir_html() — generates HTML for browsing inside a folder - 4. serve_file() — streams a file to the client for download - 5. make_handler() — creates the HTTP request handler class bound to your file roots - 6. start_server() — starts the server (blocking) - 7. start_server_background() — starts the server in a background thread (non-blocking) +1. get_local_ip() — finds this PC's WiFi IP +2. build_index_html() — generates the root HTML listing all shared items +3. build_dir_html() — generates HTML for browsing inside a folder +4. serve_file() — streams a file to the client for download +5. make_handler() — creates the HTTP request handler class bound to your file roots +6. start_server() — starts the server (blocking) +7. start_server_background() — starts the server in a background thread (non-blocking) Usage example at the bottom of this file. """ +import html import os import socket import threading import urllib.parse from http.server import HTTPServer, BaseHTTPRequestHandler - # ───────────────────────────────────────────────────────────────────────────── # LOGGING HELPER # ───────────────────────────────────────────────────────────────────────────── @@ -34,6 +33,7 @@ LOG_CALLBACK = None CONNECTION_CALLBACK = None + def _log(level: str, msg: str): """ If LOG_CALLBACK is set, route messages to it (e.g., for a GUI). @@ -44,11 +44,13 @@ def _log(level: str, msg: str): else: print(msg) + def _conn(action: str): """Notify GUI when a connection starts or ends. action='connect'|'disconnect'""" if CONNECTION_CALLBACK: CONNECTION_CALLBACK(action) + # ───────────────────────────────────────────────────────────────────────────── # NETWORK HELPERS # ───────────────────────────────────────────────────────────────────────────── @@ -56,16 +58,14 @@ def _conn(action: str): def get_local_ip() -> str: """ Find this machine's local network IP address (e.g. 192.168.1.42). - It works by opening a dummy UDP socket toward a public DNS server. No actual data is sent — the OS just picks the right local interface to route through, and we read that interface's IP. - Returns "127.0.0.1" as fallback if detection fails. """ try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.connect(("8.8.8.8", 80)) # doesn't actually send anything + sock.connect(("8.8.8.8", 80)) # doesn't actually send anything ip = sock.getsockname()[0] sock.close() return ip @@ -85,6 +85,75 @@ def format_size(size_bytes: int) -> str: return f"{size_bytes:.1f} PB" +# ───────────────────────────────────────────────────────────────────────────── +# SECURITY HELPERS +# ───────────────────────────────────────────────────────────────────────────── + +def _safe_join(root: str, rel_path: str) -> str | None: + """ + Safely join a root directory with a relative path, resolving all symlinks + and ensuring the result stays inside the root. + + FIX #1 — Path traversal via startswith() prefix bypass: + The old code used full_path.startswith(root), which is a string prefix + check, not a path boundary check. A root of '/data/share' would + incorrectly allow '/data/shareother/secret.txt' to pass. + We now append os.sep so the boundary is exact. + + FIX #2 — Symlink traversal: + os.path.normpath() collapses '..' but does NOT follow symlinks. + A symlink inside the shared folder pointing to '/etc/passwd' would + have bypassed the old check. We now use os.path.realpath() which + fully resolves symlinks before comparing. + + Returns the resolved absolute path string if safe, or None if the path + escapes the root (which the caller should treat as a 404). + """ + # Resolve the root itself (may also contain symlinks) + real_root = os.path.realpath(root) + + if rel_path: + candidate = os.path.realpath(os.path.join(real_root, rel_path)) + else: + candidate = real_root + + # Ensure the candidate is either the root itself or strictly inside it. + # We append os.sep so '/data/share' cannot match '/data/shareother/…'. + root_prefix = real_root + os.sep + if candidate != real_root and not candidate.startswith(root_prefix): + return None # Escape attempt — caller returns 404 + + return candidate + + +def _safe_filename_header(filename: str) -> str: + """ + Build a safe Content-Disposition header value for the given filename. + + FIX #4 — Content-Disposition header injection: + The old code embedded the raw filename inside double-quotes: + attachment; filename="" + A filename containing '"' or newline characters could break the header + or inject arbitrary HTTP headers. + + We now produce two tokens per RFC 6266 / RFC 5987: + - 'filename' with ASCII-safe fallback (non-ASCII and special chars stripped) + - 'filename*' with full UTF-8 percent-encoded value (RFC 5987) + + This keeps maximum compatibility across browsers while being injection-safe. + """ + # ASCII-safe fallback: keep only printable ASCII, strip quotes and slashes + ascii_name = "".join( + c for c in filename + if 32 <= ord(c) < 127 and c not in ('"', "'", '\\', '/', '\r', '\n') + ) or "download" + + # RFC 5987 encoded value: UTF-8 percent-encode everything except unreserved chars + encoded_name = urllib.parse.quote(filename, safe="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + + return f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{encoded_name}' + + # ───────────────────────────────────────────────────────────────────────────── # HTML BUILDERS # ───────────────────────────────────────────────────────────────────────────── @@ -94,18 +163,22 @@ def html_shell(title: str, body: str) -> str: Wraps any HTML body content in a full page with a dark stylesheet. Used by both the root index and directory listings. + FIX #3 (partial) — The title is HTML-escaped here to prevent XSS when + a directory path is embedded in the tag. + Args: - title: shown in the browser tab - body: the inner HTML to embed + title: shown in the browser tab (will be HTML-escaped) + body: the inner HTML to embed (must already be safe — see callers) Returns the full HTML string (UTF-8 safe). """ + safe_title = html.escape(title) return f"""<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>{title} — WiFi-Home-Server Server + {safe_title} — WiFi-Home-Server