From 66a843d6d7a6625b788b98a6249b5a8522e059df Mon Sep 17 00:00:00 2001 From: freelamb Date: Wed, 3 Jun 2026 11:33:36 +0800 Subject: [PATCH] Harden upload handling and defaults --- .github/workflows/github-actions-test.yml | 19 +-- AGENTS.md | 138 ++++++++++++++++++++++ CHANGELOG.md | 14 +++ Dockerfile | 2 +- README.md | 13 +- simple_http_server.py | 109 ++++++++++++----- tests/test_simple_http_server.py | 70 +++++++++++ 7 files changed, 325 insertions(+), 40 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/test_simple_http_server.py diff --git a/.github/workflows/github-actions-test.yml b/.github/workflows/github-actions-test.yml index 7ec9814..fd80559 100644 --- a/.github/workflows/github-actions-test.yml +++ b/.github/workflows/github-actions-test.yml @@ -13,9 +13,9 @@ jobs: linuxOS_build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.9 - name: Install dependencies @@ -29,12 +29,14 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Run tests + run: python -m unittest discover -s tests MacOS_build: runs-on: macOS-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.9 - name: Install dependencies @@ -48,12 +50,14 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Run tests + run: python -m unittest discover -s tests Windows_build: runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python 3.9 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.9 - name: Install dependencies @@ -66,4 +70,5 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - + - name: Run tests + run: python -m unittest discover -s tests diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e3533a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,138 @@ +# AGENTS.md + +This file gives future maintainers and coding agents the context needed to work on this repository safely. + +## Project Overview + +`simple_http_server` is a small Python HTTP file server with directory browsing, downloads, and browser-based file uploads. The project is intentionally lightweight and currently centers on one implementation file: + +- `simple_http_server.py`: request handler, upload handling, path translation, MIME detection, CLI parsing, and server startup. +- `Dockerfile`: container entrypoint that serves `/opt/data`. +- `.github/workflows/github-actions-test.yml`: lint-only CI across Linux, macOS, and Windows. +- `README.md`: user-facing usage notes and project status. + +Treat this project as a temporary file-sharing tool for trusted environments. It should not be presented as a hardened public internet service unless authentication, upload limits, and stronger path/file validation are added. + +## Current Behavior + +- Runs with `python simple_http_server.py 8000`. +- Accepts `--bind/-b ADDRESS`; the current default is `127.0.0.1`. +- Serves files from the current working directory. +- Lists directories when no `index.html` or `index.htm` exists. +- Adds an HTML upload form to directory listings. +- Stores uploaded files in the requested directory after sanitizing the uploaded filename. +- Avoids overwriting existing files by appending `_` to the target filename. +- Rejects uploads larger than 100 MiB. +- Uses Python standard library modules only. + +## Important Implementation Notes + +- `SimpleHTTPRequestHandler` implements `GET`, `HEAD`, and `POST`. +- `deal_post_data()` manually parses multipart upload bodies. Be careful when changing it; malformed input, missing headers, non-ASCII filenames, and large files need explicit coverage. +- `translate_path()` maps URL paths to the current working directory and strips query/fragment components. +- The server uses a threaded HTTP server so multiple requests can be handled concurrently. +- The project still contains compatibility code for Python 2, but the Dockerfile and GitHub Actions use Python 3.9. + +## Safety And Security Priorities + +When making changes, prioritize these issues first: + +1. Sanitize uploaded filenames. + Use only a safe basename, reject absolute paths, reject `..`, and avoid allowing path separators inside uploaded names. + +2. Escape all user-controlled HTML output. + Directory names, file names, upload result messages, and paths should be HTML-escaped before rendering. + +3. Preserve safer network defaults. + The default bind address is `127.0.0.1`; document `0.0.0.0` as an explicit LAN/public option. + +4. Maintain upload limits. + Large or slow uploads can exhaust disk, memory, or worker capacity. The current limit is 100 MiB. + +5. Improve request robustness. + Handle missing `Content-Type`, missing `content-length`, malformed multipart bodies, and interrupted uploads without crashing the server. + +6. Consider concurrent serving. + If multi-client support is needed, use `ThreadingHTTPServer` on Python 3 and keep Python 2 compatibility decisions explicit. + +## Development Guidelines + +- Keep the project dependency-free unless there is a strong reason to add packaging or test dependencies. +- Preserve the simple CLI experience. +- Prefer small, focused changes over broad rewrites. +- If Python 2 support is removed, update README, changelog, CI, and code comments in the same change. +- If public/network-facing behavior changes, update README and SECURITY.md. +- Do not silently change the served root directory behavior; users expect the current working directory to be served. +- Avoid introducing platform-specific behavior without checking Linux, macOS, and Windows implications. + +## Testing Guidance + +There is no dedicated test suite yet. For any behavior change, add focused tests if possible. Useful coverage areas: + +- `translate_path()` rejects or neutralizes traversal attempts. +- Directory listing escapes special characters. +- Download responses include content type, length, and last-modified headers. +- Upload accepts normal files. +- Upload handles duplicate names predictably. +- Upload rejects unsafe filenames. +- Malformed upload requests return an error page instead of crashing. +- CLI parsing supports default port, custom port, `--bind`, and `--version`. + +Before finishing a change, at minimum run: + +```bash +python3 -m py_compile simple_http_server.py +``` + +If lint dependencies are available, also run: + +```bash +flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics +flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics +``` + +For server behavior changes, manually start the server from a temporary directory and verify directory listing, download, upload, and duplicate filename handling. + +Run the unit tests with: + +```bash +python3 -m unittest discover -s tests +``` + +## Documentation Maintenance + +Keep these files aligned: + +- `simple_http_server.py`: source version in `__version__`. +- `CHANGELOG.md`: released changes. +- `README.md`: install, run, Docker, support status, and security caveats. +- `SECURITY.md`: supported versions and vulnerability contact. +- `.github/workflows/github-actions-test.yml`: supported Python versions and CI checks. + +Known documentation drift to address in future work: + +- README still shows a Travis CI badge. +- `.travis.yml` is obsolete and does not run meaningful tests. +- `CHANGELOG.md` does not reflect the current `0.3.2` source version. +- README says Python 2 and Python 3 are supported, while CI and Docker only exercise Python 3.9. + +## Release And Packaging Notes + +The project is not currently packaged for PyPI. If packaging is added, prefer a minimal modern setup and include: + +- A console script entrypoint. +- README metadata. +- License metadata. +- Python version classifiers. +- A clear decision on Python 2 support. + +Docker publishing is also not automated. If adding container releases, prefer GitHub Container Registry or Docker Hub with an explicit release workflow. + +## Good First Improvements + +- Add tests around path translation and upload filename safety. +- Sanitize and escape upload result output. +- Replace `HTTPServer` with a threaded server for Python 3. +- Update GitHub Actions to current action versions. +- Remove or replace Travis references. +- Clarify safe usage in README. diff --git a/CHANGELOG.md b/CHANGELOG.md index f63c30a..c039421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ + +## [0.3.3] (Unreleased) +### Security + +* sanitize uploaded filenames +* escape upload and directory listing output +* reject malformed upload headers and uploads larger than 100 MiB + +### Features + +* default to localhost binding +* serve requests with a threaded HTTP server +* add unit tests for helper behavior + ## [0.2.1] (2021-10-17) ### Features diff --git a/Dockerfile b/Dockerfile index 5d04803..bf7cf86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,4 @@ ENV PORT=8000 EXPOSE $PORT -CMD python /opt/http_server/simple_http_server.py ${PORT} \ No newline at end of file +CMD python /opt/http_server/simple_http_server.py --bind 0.0.0.0 ${PORT} diff --git a/README.md b/README.md index 1e36aef..1f53883 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # simple_http_server -[![Build Status](https://travis-ci.org/freelamb/simple_http_server.svg?branch=master)](https://travis-ci.org/freelamb/simple_http_server) - ## Features - ✔ simple - ✔ upload - ✔ download - ✔ support python2, python3 +- ✔ Multi-threaded ## Usage ```bash # get code @@ -19,6 +18,9 @@ $ cd simple_http_server # run server $ python simple_http_server.py 8000 +# expose to another host in a trusted network +$ python simple_http_server.py --bind 0.0.0.0 8000 + # run as docker container # 1.build the image('.' below refer to the root path of this project) docker build -t freelamb/simple_http_server . @@ -30,12 +32,17 @@ docker run -d freelamb/simple_http_server:latest ``` +## Security + +This server is intended for temporary file sharing in trusted environments. The default bind address is `127.0.0.1`; use `--bind 0.0.0.0` only when you explicitly want other hosts to connect. + +Uploaded file names are sanitized, upload results and directory listings escape user-controlled text, and uploads larger than 100 MiB are rejected. + ## Example ![](image/example.jpeg) ## Todo -- [ ] support Multi-threaded - [ ] add docker images - [ ] add to pypi ## Contributing diff --git a/simple_http_server.py b/simple_http_server.py index 2c92a78..8fe0845 100644 --- a/simple_http_server.py +++ b/simple_http_server.py @@ -22,13 +22,14 @@ import mimetypes import re import signal -from io import StringIO, BytesIO +from io import BytesIO if sys.version_info.major == 3: # Python3 + from importlib import reload from urllib.parse import quote from urllib.parse import unquote - from http.server import HTTPServer + from http.server import ThreadingHTTPServer from http.server import BaseHTTPRequestHandler else: # Python2 @@ -36,8 +37,15 @@ sys.setdefaultencoding('utf-8') from urllib import quote from urllib import unquote - from BaseHTTPServer import HTTPServer + from BaseHTTPServer import HTTPServer as BaseHTTPServer from BaseHTTPServer import BaseHTTPRequestHandler + from SocketServer import ThreadingMixIn + + class ThreadingHTTPServer(ThreadingMixIn, BaseHTTPServer): + daemon_threads = True + + +MAX_UPLOAD_SIZE = 100 * 1024 * 1024 class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): @@ -51,6 +59,7 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): """ server_version = "simple_http_server/" + __version__ + max_upload_size = MAX_UPLOAD_SIZE def do_GET(self): """Serve a GET request.""" @@ -78,7 +87,7 @@ def do_POST(self): f.write(b"Success:") else: f.write(b"Failed:") - f.write(info.encode('utf-8')) + f.write(html_escape(info).encode('utf-8')) f.write(b"
back") f.write(b"
Powered By: freelamb, check new version at ") f.write(b"") @@ -94,46 +103,67 @@ def do_POST(self): f.close() def deal_post_data(self): - boundary = self.headers["Content-Type"].split("=")[1].encode('utf-8') - remain_bytes = int(self.headers['content-length']) + content_type = self.headers.get("Content-Type", "") + match = re.search(r'boundary=([^;]+)', content_type) + if not match or "multipart/form-data" not in content_type.lower(): + return False, "Content-Type must be multipart/form-data" + boundary = match.group(1).strip().strip('"').encode('utf-8') + if not boundary: + return False, "Upload boundary is empty" + content_length = self.headers.get('content-length') + if not content_length: + return False, "Missing content-length header" + try: + remain_bytes = int(content_length) + except ValueError: + return False, "Invalid content-length header" + if remain_bytes > self.max_upload_size: + return False, "Upload exceeds the %d byte limit" % self.max_upload_size line = self.rfile.readline() remain_bytes -= len(line) if boundary not in line: return False, "Content NOT begin with boundary" line = self.rfile.readline() remain_bytes -= len(line) - fn = re.findall(r'Content-Disposition.*name="file"; filename="(.*)"', line.decode('utf-8')) + fn = re.findall(r'Content-Disposition.*name="file"; filename="(.*)"', line.decode('utf-8', 'replace')) if not fn: return False, "Can't find out file name..." + fn = sanitize_upload_filename(fn[0]) + if not fn: + return False, "Unsafe upload file name" path = translate_path(self.path) - fn = os.path.join(path, fn[0]) - while os.path.exists(fn): - fn += "_" + target_path = os.path.join(path, fn) + while os.path.exists(target_path): + target_path += "_" line = self.rfile.readline() remain_bytes -= len(line) line = self.rfile.readline() remain_bytes -= len(line) try: - out = open(fn, 'wb') + out = open(target_path, 'wb') except IOError: return False, "Can't create file to write, do you have permission to write?" - pre_line = self.rfile.readline() - remain_bytes -= len(pre_line) - while remain_bytes > 0: - line = self.rfile.readline() - remain_bytes -= len(line) - if boundary in line: - pre_line = pre_line[0:-1] - if pre_line.endswith(b'\r'): + try: + pre_line = self.rfile.readline() + remain_bytes -= len(pre_line) + while remain_bytes > 0: + line = self.rfile.readline() + remain_bytes -= len(line) + if boundary in line: pre_line = pre_line[0:-1] - out.write(pre_line) + if pre_line.endswith(b'\r'): + pre_line = pre_line[0:-1] + out.write(pre_line) + out.close() + return True, "File '%s' upload success!" % os.path.basename(target_path) + else: + out.write(pre_line) + pre_line = line + finally: + if not out.closed: out.close() - return True, "File '%s' upload success!" % fn - else: - out.write(pre_line) - pre_line = line - return False, "Unexpect Ends of data." + return False, "Unexpected end of data." def send_head(self): """Common code for GET and HEAD commands. @@ -188,7 +218,7 @@ def list_directory(self, path): return None list_dir.sort(key=lambda a: a.lower()) f = BytesIO() - display_path = escape(unquote(self.path)) + display_path = html_escape(unquote(self.path)) f.write(b'') f.write(b"\nDirectory listing for %s\n" % display_path.encode('utf-8')) f.write(b"\n

Directory listing for %s

\n" % display_path.encode('utf-8')) @@ -207,7 +237,7 @@ def list_directory(self, path): if os.path.islink(fullname): display_name = name + "@" # Note: a link to a directory displays with @ and links with / - f.write(b'
  • %s\n' % (quote(linkname).encode('utf-8'), escape(display_name).encode('utf-8'))) + f.write(b'
  • %s\n' % (quote(linkname).encode('utf-8'), html_escape(display_name).encode('utf-8'))) f.write(b"\n
    \n\n\n") length = f.tell() f.seek(0) @@ -248,6 +278,27 @@ def guess_type(self, path): }) +def html_escape(value): + """Escape user-controlled text for safe HTML output.""" + return escape(value, quote=True) + + +def sanitize_upload_filename(filename): + """Return a safe upload filename, or None when the name is unsafe.""" + filename = filename.replace('\x00', '').strip() + normalized = filename.replace('\\', '/') + drive, filename_without_drive = os.path.splitdrive(filename) + if drive or filename_without_drive != filename: + return None + if re.match(r'^[A-Za-z]:', filename): + return None + if '/' in normalized: + return None + if filename in ('', os.curdir, os.pardir): + return None + return filename + + def translate_path(path): """Translate a /-separated PATH to the local filename syntax. Components that mean special things to the local file system @@ -276,7 +327,7 @@ def signal_handler(signal, frame): def _argparse(): parser = argparse.ArgumentParser() - parser.add_argument('--bind', '-b', metavar='ADDRESS', default='0.0.0.0', help='Specify alternate bind address [default: all interfaces]') + parser.add_argument('--bind', '-b', metavar='ADDRESS', default='127.0.0.1', help='Specify alternate bind address [default: 127.0.0.1]') parser.add_argument('--version', '-v', action='version', version=__version__) parser.add_argument('port', action='store', default=8000, type=int, nargs='?', help='Specify alternate port [default: 8000]') return parser.parse_args() @@ -287,7 +338,7 @@ def main(): server_address = (args.bind, args.port) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - httpd = HTTPServer(server_address, SimpleHTTPRequestHandler) + httpd = ThreadingHTTPServer(server_address, SimpleHTTPRequestHandler) server = httpd.socket.getsockname() print("server_version: " + SimpleHTTPRequestHandler.server_version + ", python_version: " + SimpleHTTPRequestHandler.sys_version) print("sys encoding: " + sys.getdefaultencoding()) diff --git a/tests/test_simple_http_server.py b/tests/test_simple_http_server.py new file mode 100644 index 0000000..9f93166 --- /dev/null +++ b/tests/test_simple_http_server.py @@ -0,0 +1,70 @@ +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import simple_http_server + + +class HelperFunctionTests(unittest.TestCase): + def test_sanitize_upload_filename_accepts_plain_names(self): + self.assertEqual( + simple_http_server.sanitize_upload_filename("example.txt"), + "example.txt", + ) + + def test_sanitize_upload_filename_rejects_paths(self): + unsafe_names = [ + "../secret.txt", + "/tmp/secret.txt", + "nested/secret.txt", + r"nested\secret.txt", + r"C:\secret.txt", + "C:secret.txt", + ".", + "..", + "", + ] + for name in unsafe_names: + self.assertIsNone(simple_http_server.sanitize_upload_filename(name)) + + def test_html_escape_escapes_quotes_and_tags(self): + self.assertEqual( + simple_http_server.html_escape(''), + '<a href="x">', + ) + + def test_translate_path_stays_under_current_directory(self): + old_cwd = os.getcwd() + temp_dir = tempfile.mkdtemp() + try: + os.chdir(temp_dir) + try: + translated = simple_http_server.translate_path("/../../safe.txt") + finally: + os.chdir(old_cwd) + finally: + shutil.rmtree(temp_dir) + self.assertEqual( + os.path.realpath(translated), + os.path.realpath(os.path.join(temp_dir, "safe.txt")), + ) + + +class ArgumentParserTests(unittest.TestCase): + def test_default_bind_is_localhost(self): + old_argv = sys.argv + sys.argv = ["simple_http_server.py"] + try: + args = simple_http_server._argparse() + finally: + sys.argv = old_argv + self.assertEqual(args.bind, "127.0.0.1") + self.assertEqual(args.port, 8000) + + +if __name__ == "__main__": + unittest.main()