├── .coveragerc ├── .github └── workflows │ ├── build_and_release.yml │ └── tests_and_coverage.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── __globals__.py ├── bad.ext ├── bar │ ├── __init__.py │ └── baz.py ├── environ.py ├── exc.py ├── exit.py ├── foo.py ├── home │ └── index.py ├── hybrid.py ├── import.py ├── main.py ├── path.py ├── static │ ├── hello.gif │ ├── hello.mp4 │ └── index.html └── syntax.py ├── httpout ├── __init__.py ├── __main__.py ├── exceptions.py ├── httpout.py ├── request.py ├── response.py └── utils │ ├── __init__.py │ ├── modules.py │ └── modules.pyx ├── pyproject.toml ├── requirements.txt ├── setup.py ├── sonar-project.properties └── tests ├── __init__.py ├── __main__.py ├── test_cli.py ├── test_http.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | concurrency = multiprocessing,thread 3 | parallel = True 4 | relative_files = True 5 | source = httpout/ 6 | -------------------------------------------------------------------------------- /.github/workflows/build_and_release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: ['build', 'release'] 6 | pull_request: 7 | branches: ['main', 'master'] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build_sdist: 12 | if: | 13 | startsWith(github.ref_name, 'build') || 14 | startsWith(github.ref_name, 'release') 15 | name: Build sdist 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m venv --system-site-packages .local 23 | echo "$HOME/.local/bin" >> $GITHUB_PATH 24 | python -m pip install --upgrade pip build setuptools Cython 25 | 26 | - name: Build sdist and verify its integrity 27 | run: | 28 | python -m build --sdist --no-isolation 29 | for f in dist/httpout-*.tar.gz; do gzip -t "$f"; done 30 | 31 | - uses: actions/upload-artifact@v4 32 | with: 33 | name: dist-sdist 34 | path: dist/httpout-*.tar.gz 35 | 36 | build_wheels: 37 | if: | 38 | startsWith(github.ref_name, 'build') || 39 | startsWith(github.ref_name, 'release') 40 | name: Build ${{ matrix.cibw_python }} on ${{ matrix.os }} 41 | runs-on: ${{ matrix.os }} 42 | strategy: 43 | matrix: 44 | os: [ubuntu-latest] 45 | cibw_python: 46 | - 'cp37-*' 47 | - 'cp38-*' 48 | - 'cp39-*' 49 | - 'cp310-*' 50 | - 'cp311-*' 51 | - 'cp312-*' 52 | - 'cp313-*' 53 | - 'pp310-*' 54 | cibw_arch: ['x86_64'] 55 | timeout-minutes: 10 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - name: Install dependencies 60 | run: | 61 | python -m venv --system-site-packages .local 62 | echo "$HOME/.local/bin" >> $GITHUB_PATH 63 | python -m pip install --upgrade pip 64 | python -m pip install cibuildwheel==2.21.1 65 | PYTHON_VERSION=${{ matrix.cibw_python }} 66 | echo "PYTHON_VERSION=${PYTHON_VERSION%-*}" >> $GITHUB_ENV 67 | 68 | - name: Build wheels 69 | run: | 70 | python -m cibuildwheel --output-dir dist 71 | env: 72 | CIBW_BUILD_VERBOSITY: 1 73 | CIBW_BUILD: ${{ matrix.cibw_python }} 74 | CIBW_ARCHS: ${{ matrix.cibw_arch }} 75 | 76 | - name: Verify the existence of the '.so' files 77 | run: | 78 | for f in dist/httpout-*.whl; do 79 | unzip -l "$f" | grep '\shttpout/utils/modules\..*\.so$'; 80 | done 81 | 82 | - uses: actions/upload-artifact@v4 83 | with: 84 | name: dist-${{ matrix.os }}-${{ env.PYTHON_VERSION }} 85 | path: dist/httpout-*.whl 86 | 87 | release: 88 | if: | 89 | startsWith(github.ref_name, 'build') || 90 | startsWith(github.ref_name, 'release') 91 | name: Upload release to PyPI 92 | needs: ['build_sdist', 'build_wheels'] 93 | runs-on: ubuntu-latest 94 | environment: 95 | name: pypi 96 | url: https://pypi.org/p/httpout 97 | permissions: 98 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 99 | steps: 100 | - uses: actions/download-artifact@v4 101 | with: 102 | path: dist 103 | pattern: dist-* 104 | merge-multiple: true 105 | 106 | - run: | 107 | tree -L 2 108 | 109 | - name: Publish to PyPI 110 | if: ${{ startsWith(github.ref_name, 'release') }} 111 | uses: pypa/gh-action-pypi-publish@release/v1 112 | -------------------------------------------------------------------------------- /.github/workflows/tests_and_coverage.yml: -------------------------------------------------------------------------------- 1 | name: Tests and Coverage 2 | 3 | on: 4 | push: 5 | branches: ['testing'] 6 | pull_request: 7 | branches: ['main', 'master'] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | tests: 12 | name: Python ${{ matrix.python_version }} on ${{ matrix.os }} 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | python_version: 17 | - '3.13' 18 | - '3.12' 19 | - '3.11' 20 | - '3.9' 21 | - '3.8' 22 | - '3.7' 23 | - 'pypy3.10' 24 | os: ['ubuntu-20.04', 'windows-latest'] 25 | timeout-minutes: 10 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 2 30 | 31 | - name: List the modified files 32 | run: echo "FILES_MODIFIED=$(git diff --name-only HEAD HEAD^ | xargs)" >> $GITHUB_ENV 33 | if: ${{ !startsWith(matrix.os, 'windows-') }} 34 | 35 | - name: List the modified files (Windows) 36 | run: | 37 | $FILES_MODIFIED = $(git diff --name-only HEAD HEAD^) -join ' ' 38 | echo "FILES_MODIFIED=$FILES_MODIFIED" | Out-File -FilePath $env:GITHUB_ENV 39 | if: ${{ startsWith(matrix.os, 'windows-') }} 40 | 41 | - name: Setup Python 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: ${{ matrix.python_version }} 45 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 46 | 47 | - name: Install dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | python -m pip install --upgrade coverage 51 | python -m pip install --upgrade awaiter 52 | python -m pip install --upgrade tremolo 53 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 54 | 55 | - name: Lint 56 | run: | 57 | python -m pip install --upgrade bandit 58 | python -m bandit --recursive httpout/ 59 | python -m pip install --upgrade flake8 60 | python -m flake8 httpout/ 61 | if: | 62 | contains(env.FILES_MODIFIED, '.py') && 63 | matrix.os == 'ubuntu-20.04' && matrix.python_version == '3.13' 64 | 65 | - name: Run tests 66 | run: python -m tests 67 | if: ${{ contains(env.FILES_MODIFIED, '.py') && matrix.python_version != '3.13' }} 68 | 69 | - name: Run tests with coverage 70 | run: | 71 | python -m coverage run -m tests 72 | python -m coverage combine 73 | mkdir artifact && mv .coverage artifact/.coverage.${{ matrix.os }} 74 | if: | 75 | contains(env.FILES_MODIFIED, '.py') && 76 | matrix.python_version == '3.13' && !startsWith(matrix.os, 'windows-') 77 | 78 | - name: Run tests with coverage (Windows) 79 | run: | 80 | python -m coverage run -m tests 81 | python -m coverage combine 82 | mkdir artifact && move .coverage artifact\.coverage.windows 83 | shell: cmd 84 | if: | 85 | contains(env.FILES_MODIFIED, '.py') && 86 | matrix.python_version == '3.13' && startsWith(matrix.os, 'windows-') 87 | 88 | - uses: actions/upload-artifact@v4 89 | with: 90 | name: artifact-${{ matrix.os }} 91 | path: artifact 92 | include-hidden-files: true 93 | if: ${{ contains(env.FILES_MODIFIED, '.py') && matrix.python_version == '3.13' }} 94 | 95 | report: 96 | name: Upload coverage to SonarCloud Scan 97 | needs: ['tests'] 98 | runs-on: ubuntu-latest 99 | steps: 100 | - uses: actions/checkout@v4 101 | with: 102 | fetch-depth: 2 103 | 104 | - name: List the modified files 105 | run: echo "FILES_MODIFIED=$(git diff --name-only HEAD HEAD^ | xargs)" >> $GITHUB_ENV 106 | 107 | - name: Install dependencies 108 | run: | 109 | python -m venv --system-site-packages .local 110 | echo "$HOME/.local/bin" >> $GITHUB_PATH 111 | python -m pip install --upgrade pip 112 | python -m pip install --upgrade coverage 113 | python -m pip install --upgrade awaiter 114 | python -m pip install --upgrade tremolo 115 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 116 | 117 | - uses: actions/download-artifact@v4 118 | with: 119 | path: artifact 120 | pattern: artifact-* 121 | merge-multiple: true 122 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 123 | 124 | - name: Combine and view report 125 | run: | 126 | python -m coverage combine artifact 127 | python -m coverage report --show-missing --skip-covered 128 | python -m coverage xml 129 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 130 | 131 | - uses: sonarsource/sonarcloud-github-action@v3 132 | env: 133 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needed to get PR information, if any 134 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 135 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 136 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | *.py,cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | cover/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | .pybuilder/ 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | # For a library or package, you might want to ignore these files since the code is 86 | # intended to run in multiple environments; otherwise, check them in: 87 | # .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # poetry 97 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 98 | # This is especially recommended for binary packages to ensure reproducibility, and is more 99 | # commonly ignored for libraries. 100 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 101 | #poetry.lock 102 | 103 | # pdm 104 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 105 | #pdm.lock 106 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 107 | # in version control. 108 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 109 | .pdm.toml 110 | .pdm-python 111 | .pdm-build/ 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 nggit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft httpout 2 | prune tests 3 | global-exclude *.py[cod] __pycache__ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpout 2 | [](https://sonarcloud.io/summary/new_code?id=nggit_httpout) 3 | [](https://sonarcloud.io/summary/new_code?id=nggit_httpout) 4 | 5 | httpout is a runtime environment for Python files. It allows you to execute your Python scripts from a web URL, the `print()` output goes to your browser. 6 | 7 | This is the classic way to deploy your scripts to the web. 8 | You just need to put your regular `.py` files as well as other static files in the document root and each will be routable from the web. No server reload is required! 9 | 10 | It provides a native experience for running your script from the web. 11 | 12 | ## How does it work? 13 | httpout will assign every route either like `/hello.py` or `/index.py` with the name `__main__` and executes its corresponding file as a module in a thread pool. 14 | Monkey patching is done at the module-level by hijacking the [`__import__`](https://docs.python.org/3/library/functions.html#import__). 15 | 16 | In the submodules perspective, the `__main__` object points to the main module such as `/hello.py`, rather than pointing to `sys.modules['__main__']` or the web server itself. 17 | 18 | httpout does not perform a cache mechanism like standard imports or with [sys.modules](https://docs.python.org/3/library/sys.html#sys.modules) to avoid conflicts with other modules / requests. Because each request must have its own namespace. 19 | 20 | To keep it simple, only the main module is cached (as code object). 21 | The cache will be valid during HTTP Keep-Alive. 22 | So if you just change the script there is no need to reload the server process, just wait until the connection is lost. 23 | 24 | Keep in mind this may not work for running complex python scripts, 25 | e.g. running other server processes or multithreaded applications as each route is not a real main thread. 26 | 27 |  28 | 29 | ## Installation 30 | ``` 31 | python3 -m pip install --upgrade httpout 32 | ``` 33 | 34 | ## Example 35 | ```python 36 | # hello.py 37 | import time 38 | 39 | 40 | print('
Hello...') 41 | 42 | time.sleep(1) 43 | print('and') 44 | 45 | time.sleep(2) 46 | print('Bye!') 47 | ``` 48 | 49 | Put `hello.py` in the `examples/` folder, then run the httpout server with: 50 | ``` 51 | python3 -m httpout --port 8000 examples/ 52 | ``` 53 | 54 | and your `hello.py` can be accessed at [http://localhost:8000/hello.py](http://localhost:8000/hello.py). 55 | If you don't want the `.py` suffix in the URL, you can instead create a `hello/` folder with `index.py` inside. 56 | 57 | ## Handling forms 58 | This is an overview of how to view request methods and read form data. 59 | 60 | ```python 61 | # form.py 62 | from httpout import wait, request, response 63 | 64 | 65 | method_str = request.environ['REQUEST_METHOD'] 66 | method_bytes = request.method 67 | 68 | 69 | if method_str != 'POST': 70 | response.set_status(405, 'Method Not Allowed') 71 | print('Method Not Allowed') 72 | exit() 73 | 74 | 75 | # we can't use await outside the async context 76 | # so wait() is used here because request.form() is a coroutine object 77 | form_data = wait(request.form()) 78 | 79 | print(method_str, method_bytes, form_data) 80 | ``` 81 | 82 | It can also be written this way: 83 | ```python 84 | # form.py 85 | from httpout import run, request, response 86 | 87 | 88 | method_str = request.environ['REQUEST_METHOD'] 89 | method_bytes = request.method 90 | 91 | 92 | if method_str != 'POST': 93 | response.set_status(405, 'Method Not Allowed') 94 | print('Method Not Allowed') 95 | exit() 96 | 97 | 98 | async def main(): 99 | # using await instead of wait() 100 | form_data = await request.form() 101 | 102 | print(method_str, method_bytes, form_data) 103 | 104 | 105 | run(main()) 106 | ``` 107 | 108 | Then you can do: 109 | ``` 110 | curl -d foo=bar http://localhost:8000/form.py 111 | ``` 112 | 113 | ## Features 114 | httpout is designed to be fun. It's not built for perfectionists. httpout has: 115 | - A [hybrid async and sync](https://httpout.github.io/hybrid.html), the two worlds can coexist in your script seamlessly; It's not yet time to drop your favorite synchronous library 116 | - More lightweight than running CGI scripts 117 | - Your `print()`s are sent immediately line by line without waiting for the script to finish like a typical CGI 118 | - No need for a templating engine, just do `if-else` and `print()` making your script portable for both CLI and web 119 | - And more 120 | 121 | ## Security 122 | It's important to note that httpout only focuses on request security; 123 | to ensure that [path traversal](https://en.wikipedia.org/wiki/Directory_traversal_attack) through the URL never happens. 124 | 125 | httpout will never validate the script you write, 126 | you can still access objects like `os`, `eval()`, `open()`, even traversal out of the document root. 127 | So this stage is your responsibility. 128 | 129 | FYI, PHP used to have something called [Safe Mode](https://web.archive.org/web/20201014032613/https://www.php.net/manual/en/features.safe-mode.php), but it was deemed *architecturally incorrect*, so they removed it. 130 | 131 | > The PHP safe mode is an attempt to solve the shared-server security problem. 132 | > It is architecturally incorrect to try to solve this problem at the PHP level, 133 | > but since the alternatives at the web server and OS levels aren't very realistic, 134 | > many people, especially ISP's, use safe mode for now. 135 | 136 | ## License 137 | MIT License 138 | -------------------------------------------------------------------------------- /examples/__globals__.py: -------------------------------------------------------------------------------- 1 | 2 | import __main__ 3 | 4 | from httpout import app, modules 5 | 6 | # just for testing. the only thing that matters here is the `app` :) 7 | assert __main__ is modules['__globals__'] 8 | 9 | # in routes it should be available as `__globals__.counter` 10 | # you can't access this from inside the middleware, btw 11 | counter = 0 12 | 13 | 14 | # this middleware is usually not placed here but in a separate package 15 | class _MyMiddleware: 16 | def __init__(self, app): 17 | app.add_middleware(self._on_request, 'request') 18 | app.add_middleware(self._on_response, 'response') 19 | 20 | async def _on_request(self, **server): 21 | response = server['response'] 22 | 23 | response.set_header('X-Powered-By', 'foo') 24 | response.set_header('X-Debug', 'bar') 25 | 26 | async def _on_response(self, **server): 27 | response = server['response'] 28 | 29 | if not response.headers_sent(): 30 | del response.headers[b'x-debug'] 31 | 32 | 33 | app.logger.info('entering %s', __file__) 34 | 35 | # apply middleware 36 | _MyMiddleware(app) 37 | 38 | 39 | @app.on_worker_stop 40 | async def _on_worker_stop(**worker): 41 | app.logger.info('exiting %s', __file__) 42 | 43 | # incremented in `main.py` 44 | assert counter > 0 45 | -------------------------------------------------------------------------------- /examples/bad.ext: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/examples/bad.ext -------------------------------------------------------------------------------- /examples/bar/__init__.py: -------------------------------------------------------------------------------- 1 | # package: bar 2 | 3 | # test import itself 4 | import bar 5 | import httpout.run 6 | 7 | # test relative imports 8 | from . import baz # noqa: F401 9 | from .baz import world # noqa: F401 10 | 11 | assert httpout is bar 12 | assert httpout.run is run # noqa: F821 13 | -------------------------------------------------------------------------------- /examples/bar/baz.py: -------------------------------------------------------------------------------- 1 | 2 | import __main__ 3 | 4 | 5 | def world(): 6 | print('World!') 7 | __main__.MESSAGES[0] = 'OK' 8 | -------------------------------------------------------------------------------- /examples/environ.py: -------------------------------------------------------------------------------- 1 | 2 | from httpout import request 3 | 4 | print(request.method, request.environ['REQUEST_URI']) 5 | -------------------------------------------------------------------------------- /examples/exc.py: -------------------------------------------------------------------------------- 1 | 2 | from httpout import run 3 | from httpout.exceptions import WebSocketException 4 | 5 | 6 | print('Hi') 7 | 8 | 9 | async def main(): 10 | raise WebSocketException('') 11 | 12 | 13 | run(main()) 14 | -------------------------------------------------------------------------------- /examples/exit.py: -------------------------------------------------------------------------------- 1 | 2 | from httpout import __server__ 3 | 4 | print('Hello, ', end='') 5 | 6 | if __server__['QUERY_STRING']: 7 | exit(__server__['QUERY_STRING'] + '!\n') 8 | 9 | exit(0) 10 | -------------------------------------------------------------------------------- /examples/foo.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import __main__ 4 | 5 | from httpout import response 6 | from bar import world 7 | 8 | response.append_header('Foo', 'bar') 9 | response.set_header('Foo', 'baz') 10 | 11 | 12 | class Foo: 13 | def __del__(self): 14 | if sys is None or sys.implementation.name == 'cpython': 15 | print(hello) 16 | 17 | async def hello(self): 18 | response.set_cookie('foo', 'bar') 19 | response.set_status(201, 'Created') 20 | response.set_content_type('text/plain') 21 | await response.write(b'Hello\n') 22 | world() 23 | 24 | if sys.implementation.name != 'cpython': 25 | __main__.MESSAGES.append(None) 26 | 27 | 28 | foo = Foo() 29 | hello = foo.hello 30 | 31 | # test recursive 32 | foo.bar = foo 33 | -------------------------------------------------------------------------------- /examples/home/index.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/examples/home/index.py -------------------------------------------------------------------------------- /examples/hybrid.py: -------------------------------------------------------------------------------- 1 | 2 | import asyncio 3 | 4 | 5 | async def main(): 6 | await asyncio.sleep(0.5) 7 | print('Done!') 8 | 9 | 10 | run(main()) # noqa: F821 11 | print('OK') 12 | # leaves the thread while main() is still running 13 | # should print the 'OK' first 14 | -------------------------------------------------------------------------------- /examples/import.py: -------------------------------------------------------------------------------- 1 | 2 | from httpout import globals, context, app # noqa: F401 3 | from httpout import foo # noqa: F401 4 | -------------------------------------------------------------------------------- /examples/main.py: -------------------------------------------------------------------------------- 1 | 2 | import asyncio 3 | import time 4 | 5 | # `__globals__` and `wait` are built-in. 6 | # but can be optionally imported to satisfy linters 7 | from httpout import __globals__, wait 8 | from foo import hello 9 | 10 | MESSAGES = ['Done!'] 11 | 12 | time.sleep(0.1) 13 | 14 | 15 | async def main(): 16 | __globals__.counter += 1 17 | 18 | await hello() 19 | await asyncio.sleep(0.1) 20 | 21 | 22 | if __name__ == '__main__': 23 | wait(main()) 24 | 25 | for message in MESSAGES: 26 | print(message) 27 | -------------------------------------------------------------------------------- /examples/path.py: -------------------------------------------------------------------------------- 1 | 2 | print(__server__['SCRIPT_NAME'], __server__['PATH_INFO']) # noqa: F821 3 | -------------------------------------------------------------------------------- /examples/static/hello.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/examples/static/hello.gif -------------------------------------------------------------------------------- /examples/static/hello.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/examples/static/hello.mp4 -------------------------------------------------------------------------------- /examples/static/index.html: -------------------------------------------------------------------------------- 1 |
Hello, World!
-------------------------------------------------------------------------------- /examples/syntax.py: -------------------------------------------------------------------------------- 1 | 2 | return # noqa: 706 3 | -------------------------------------------------------------------------------- /httpout/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | __version__ = '0.1.0' 4 | __all__ = ('HTTPOut',) 5 | 6 | from .httpout import HTTPOut # noqa: E402 7 | -------------------------------------------------------------------------------- /httpout/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import sys 4 | 5 | import tremolo 6 | 7 | from httpout import __version__, HTTPOut 8 | 9 | app = tremolo.Application() 10 | 11 | HTTPOut(app) 12 | 13 | 14 | def usage(**context): 15 | print('Usage: python3 -m httpout [OPTIONS] DOCUMENT_ROOT') 16 | print() 17 | print('Example:') 18 | print(' python3 -m httpout --port 8080 /home/user/public_html') 19 | print(' python3 -m httpout --debug --port 8080 /home/user/public_html') 20 | print() 21 | print('Options:') 22 | print(' --host Listen host. Defaults to "127.0.0.1"') 23 | print(' --port Listen port. Defaults to 8000') 24 | print(' --bind Address to bind.') 25 | print(' Instead of using --host or --port') 26 | print(' E.g. "127.0.0.1:8000" or "/tmp/file.sock"') # noqa: E501 27 | print(' Multiple binds can be separated by commas') # noqa: E501 28 | print(' E.g. "127.0.0.1:8000,:8001"') 29 | print(' --worker-num Number of worker processes. Defaults to 1') # noqa: E501 30 | print(' --thread-pool-size Number of executor threads per process') # noqa: E501 31 | print(' Defaults to 5') 32 | print(' --limit-memory Restart the worker if this limit (in KiB) is reached') # noqa: E501 33 | print(' (Linux-only). Defaults to 0 or unlimited') # noqa: E501 34 | print(' --ssl-cert SSL certificate location') 35 | print(' E.g. "/path/to/fullchain.pem"') 36 | print(' --ssl-key SSL private key location') 37 | print(' E.g. "/path/to/privkey.pem"') 38 | print(' --directory-index Index files to be served on directory-based URLs') # noqa: E501 39 | print(' Must be separated by commas. E.g. "index.py,index.html"') # noqa: E501 40 | print(' --debug Enable debug mode') 41 | print(' Intended for development') 42 | print(' --log-level Defaults to "DEBUG". See') 43 | print(' https://docs.python.org/3/library/logging.html#levels') # noqa: E501 44 | print(' --log-fmt Python\'s log format. If empty defaults to "%(message)s"') # noqa: E501 45 | print(' --loop A fully qualified event loop name') 46 | print(' E.g. "asyncio" or "asyncio.SelectorEventLoop"') # noqa: E501 47 | print(' It expects the respective module to already be present') # noqa: E501 48 | print(' --shutdown-timeout Maximum number of seconds to wait after SIGTERM is') # noqa: E501 49 | print(' sent to a worker process. Defaults to 30 (seconds)') # noqa: E501 50 | print(' --version Print the httpout version and exit') 51 | print(' --help Show this help and exit') 52 | print() 53 | print('Please run "python3 -m tremolo --help" to see more available options') # noqa: E501 54 | return 0 55 | 56 | 57 | def bind(value='', **context): 58 | context['options']['host'] = None 59 | 60 | try: 61 | for bind in value.split(','): 62 | if ':\\' not in bind and ':' in bind: 63 | host, port = bind.rsplit(':', 1) 64 | app.listen(int(port), host=host.strip('[]') or None) 65 | else: 66 | app.listen(bind) 67 | except ValueError: 68 | print(f'Invalid --bind value "{value}"') 69 | return 1 70 | 71 | 72 | def version(**context): 73 | print( 74 | 'httpout %s (tremolo %s, %s %d.%d.%d, %s)' % 75 | (__version__, 76 | tremolo.__version__, 77 | sys.implementation.name, 78 | *sys.version_info[:3], 79 | sys.platform) 80 | ) 81 | return 0 82 | 83 | 84 | def threads(value, **context): 85 | try: 86 | context['options']['thread_pool_size'] = int(value) 87 | except ValueError: 88 | print( 89 | f'Invalid --thread-pool-size value "{value}". It must be a number' 90 | ) 91 | return 1 92 | 93 | 94 | def indexes(value, **context): 95 | context['options']['directory_index'] = value.split(',') 96 | 97 | 98 | if __name__ == '__main__': 99 | options = tremolo.utils.parse_args( 100 | help=usage, bind=bind, version=version, thread_pool_size=threads, 101 | directory_index=indexes 102 | ) 103 | 104 | if sys.argv[-1] != sys.argv[0] and not sys.argv[-1].startswith('-'): 105 | options['document_root'] = sys.argv[-1] 106 | 107 | if 'document_root' not in options: 108 | print('You must specify DOCUMENT_ROOT. Use "--help" for help') 109 | sys.exit(1) 110 | 111 | if 'server_name' not in options: 112 | options['server_name'] = 'HTTPOut' 113 | 114 | app.run(**options) 115 | -------------------------------------------------------------------------------- /httpout/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | from tremolo.exceptions import ( # noqa: F401 4 | WebSocketException, 5 | WebSocketClientClosed, 6 | WebSocketServerClosed 7 | ) 8 | -------------------------------------------------------------------------------- /httpout/httpout.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import asyncio 4 | import builtins 5 | import os 6 | import sys 7 | 8 | from types import ModuleType 9 | 10 | from awaiter import MultiThreadExecutor 11 | from tremolo.exceptions import BadRequest, NotFound, Forbidden 12 | from tremolo.lib.websocket import WebSocket 13 | from tremolo.utils import html_escape 14 | 15 | from .request import HTTPRequest 16 | from .response import HTTPResponse 17 | from .utils import ( 18 | is_safe_path, new_module, exec_module, cleanup_modules, mime_types 19 | ) 20 | 21 | 22 | class HTTPOut: 23 | def __init__(self, app): 24 | app.add_hook(self._on_worker_start, 'worker_start') 25 | app.add_hook(self._on_worker_stop, 'worker_stop') 26 | app.add_middleware(self._on_request, 'request', priority=9999) # low 27 | app.add_middleware(self._on_close, 'close') 28 | 29 | async def _on_worker_start(self, **worker): 30 | loop = worker['loop'] 31 | logger = worker['logger'] 32 | g = worker['globals'] 33 | thread_pool_size = g.options.get('thread_pool_size', 5) 34 | document_root = os.path.abspath( 35 | g.options.get('document_root', os.getcwd()) 36 | ) 37 | g.options['document_root'] = document_root 38 | g.options['directory_index'] = g.options.get( 39 | 'directory_index', ['index.py', 'index.html'] 40 | ) 41 | 42 | logger.info('entering directory: %s', document_root) 43 | os.chdir(document_root) 44 | sys.path.insert(0, document_root) 45 | 46 | # provides __globals__, a worker-level context 47 | module = new_module('__globals__') 48 | worker['__globals__'] = module or ModuleType('__globals__') 49 | worker['modules'] = {'__globals__': worker['__globals__']} 50 | py_import = builtins.__import__ 51 | 52 | def wait(coro, timeout=None): 53 | return asyncio.run_coroutine_threadsafe(coro, loop).result(timeout) 54 | 55 | def load_module(name, globals, level=0): 56 | if '__server__' in globals: 57 | modules = globals['__main__'].__server__['modules'] 58 | else: 59 | modules = worker['modules'] 60 | 61 | if name in modules: 62 | # already imported 63 | return modules[name] 64 | 65 | module = new_module(name, level, document_root) 66 | 67 | if module: 68 | logger.info('%s: importing %s', globals['__name__'], name) 69 | 70 | if '__server__' in globals: 71 | module.__main__ = globals['__main__'] 72 | module.__server__ = globals['__main__'].__server__ 73 | module.print = globals['__main__'].print 74 | module.run = globals['__main__'].run 75 | module.wait = wait 76 | 77 | modules[name] = module 78 | exec_module(module) 79 | 80 | return module 81 | 82 | def ho_import(name, globals=None, locals=None, fromlist=(), level=0): 83 | if (name not in sys.builtin_module_names and 84 | globals is not None and '__file__' in globals and 85 | globals['__file__'].startswith(document_root)): 86 | # satisfy import __main__ 87 | if name == '__main__': 88 | logger.info('%s: importing __main__', globals['__name__']) 89 | 90 | if globals['__name__'] == '__globals__': 91 | return worker['__globals__'] 92 | 93 | return globals['__main__'] 94 | 95 | oldname = name 96 | 97 | if level > 0: 98 | name = globals['__name__'].rsplit('.', level)[0] 99 | 100 | if oldname != '': 101 | name = f'{name}.{oldname}' 102 | 103 | module = load_module(name, globals, level) 104 | 105 | if module: 106 | if oldname == '': 107 | # relative import 108 | for child in fromlist: 109 | module.__dict__[child] = load_module( 110 | f'{name}.{child}', globals 111 | ) 112 | 113 | return module 114 | 115 | parent, _, child = name.partition('.') 116 | 117 | if parent == 'httpout' and (child == '' or child in globals): 118 | if '__server__' in globals: 119 | module = globals['__main__'].__server__['modules'][ 120 | globals['__name__'] 121 | ] 122 | else: 123 | module = worker['modules'][globals['__name__']] 124 | 125 | # handles virtual imports, 126 | # e.g. from httpout import request, response 127 | if fromlist: 128 | for child in fromlist: 129 | if child in module.__dict__: 130 | continue 131 | 132 | if ('__server__' in globals and 133 | child in module.__server__): 134 | module.__dict__[child] = module.__server__[ 135 | child 136 | ] 137 | elif child in worker: 138 | module.__dict__[child] = worker[child] 139 | else: 140 | raise ImportError( 141 | f'cannot import name \'{child}\' ' 142 | f'from \'{name}\'' 143 | ) 144 | 145 | return module 146 | 147 | return py_import(name, globals, locals, fromlist, level) 148 | 149 | builtins.__import__ = ho_import 150 | builtins.__globals__ = worker['__globals__'] 151 | builtins.exit = sys.exit 152 | 153 | g.wait = wait 154 | g.caches = {} 155 | g.executor = MultiThreadExecutor(thread_pool_size) 156 | g.executor.start() 157 | 158 | if module: 159 | exec_module(module) 160 | 161 | async def _on_worker_stop(self, **worker): 162 | g = worker['globals'] 163 | 164 | await g.executor.shutdown() 165 | 166 | async def _on_request(self, **server): 167 | request = server['request'] 168 | response = server['response'] 169 | logger = server['logger'] 170 | ctx = server['context'] 171 | g = server['globals'] 172 | document_root = g.options['document_root'] 173 | 174 | if not request.is_valid: 175 | raise BadRequest 176 | 177 | # no need to unquote path 178 | # in fact, the '%' character in the path will be rejected. 179 | # httpout strictly uses A-Z a-z 0-9 - _ . for directory names 180 | # which does not need the use of percent-encoding 181 | path = request.path.decode('latin-1') 182 | path_info = path[(path + '.py/').find('.py/') + 3:] 183 | 184 | if path_info: 185 | path = path[:path.rfind(path_info)] 186 | path_info = os.path.normpath(path_info).replace(os.sep, '/') 187 | 188 | module_path = os.path.abspath( 189 | os.path.join(document_root, os.path.normpath(path.lstrip('/'))) 190 | ) 191 | 192 | if not module_path.startswith(document_root): 193 | raise Forbidden('Path traversal is not allowed') 194 | 195 | if '/.' in path and not path.startswith('/.well-known/'): 196 | raise Forbidden('Access to dotfiles is prohibited') 197 | 198 | if not is_safe_path(path): 199 | raise Forbidden('Unsafe URL detected') 200 | 201 | dirname, basename = os.path.split(module_path) 202 | ext = os.path.splitext(basename)[-1] 203 | request_uri = request.url.decode('latin-1') 204 | 205 | if ext == '': 206 | dirname = module_path 207 | 208 | # no file extension in the URL, try index.py, index.html, etc. 209 | for basename in g.options['directory_index']: 210 | module_path = os.path.join(dirname, basename) 211 | ext = os.path.splitext(basename)[-1] 212 | 213 | if os.path.exists(module_path): 214 | break 215 | 216 | if basename.startswith('_') or not os.path.isfile(module_path): 217 | raise NotFound('URL not found:', html_escape(request_uri)) 218 | 219 | if ext == '.py': 220 | # begin loading the module 221 | logger.info('%s -> __main__: %s', path, module_path) 222 | 223 | server['request'] = HTTPRequest(request, server) 224 | server['response'] = HTTPResponse(response) 225 | 226 | if (g.options['ws'] and 227 | b'sec-websocket-key' in request.headers and 228 | b'upgrade' in request.headers and 229 | request.headers[b'upgrade'].lower() == b'websocket'): 230 | server['websocket'] = WebSocket(request, response) 231 | else: 232 | server['websocket'] = None 233 | 234 | server['REQUEST_METHOD'] = request.method.decode('latin-1') 235 | server['SCRIPT_NAME'] = module_path[len(document_root):].replace( 236 | os.sep, '/' 237 | ) 238 | server['PATH_INFO'] = path_info 239 | server['QUERY_STRING'] = request.query_string.decode('latin-1') 240 | server['REMOTE_ADDR'] = request.ip.decode('latin-1') 241 | server['HTTP_HOST'] = request.host.decode('latin-1') 242 | server['REQUEST_URI'] = request_uri 243 | server['REQUEST_SCHEME'] = request.scheme.decode('latin-1') 244 | server['DOCUMENT_ROOT'] = document_root 245 | 246 | module = ModuleType('__main__') 247 | module.__file__ = module_path 248 | module.__main__ = module 249 | server['modules'] = {'__main__': module} 250 | module.__server__ = server 251 | module.print = server['response'].print 252 | module.run = server['response'].run_coroutine 253 | module.wait = g.wait 254 | code = g.caches.get(module_path, None) 255 | 256 | if code: 257 | logger.info('%s: using cache', path) 258 | 259 | try: 260 | # execute module in another thread 261 | result = await g.executor.submit(exec_module, module, code) 262 | await server['response'].join() 263 | 264 | if result: 265 | g.caches[module_path] = result 266 | logger.info('%s: cached', path) 267 | else: 268 | # cache is going to be deleted on @app.on_close 269 | # but it can be delayed on a Keep-Alive request 270 | ctx.module_path = module_path 271 | except BaseException as exc: 272 | await server['response'].join() 273 | await server['response'].handle_exception(exc) 274 | finally: 275 | await g.executor.submit( 276 | cleanup_modules, server['modules'], g.options['debug'] 277 | ) 278 | await server['response'].join() 279 | server['modules'].clear() 280 | # EOF 281 | return b'' 282 | 283 | # not a module 284 | if ext not in mime_types: 285 | raise Forbidden(f'Disallowed file extension: {ext}') 286 | 287 | logger.info('%s -> %s: %s', path, mime_types[ext], module_path) 288 | await response.sendfile( 289 | module_path, content_type=mime_types[ext], executor=g.executor 290 | ) 291 | # exit middleware without closing the connection 292 | return True 293 | 294 | async def _on_close(self, **server): 295 | logger = server['logger'] 296 | ctx = server['context'] 297 | g = server['globals'] 298 | 299 | if 'module_path' in ctx: 300 | g.caches[ctx.module_path] = None 301 | logger.info('cache deleted: %s', ctx.module_path) 302 | -------------------------------------------------------------------------------- /httpout/request.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | 4 | class HTTPRequest: 5 | def __init__(self, request, environ): 6 | self.request = request 7 | self.environ = environ 8 | 9 | def __getattr__(self, name): 10 | return getattr(self.request, name) 11 | -------------------------------------------------------------------------------- /httpout/response.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import asyncio 4 | import concurrent.futures 5 | 6 | from traceback import TracebackException 7 | from tremolo.utils import html_escape 8 | 9 | 10 | class HTTPResponse: 11 | def __init__(self, response): 12 | self.response = response 13 | self.loop = response.request.protocol.loop 14 | self.logger = response.request.protocol.logger 15 | self.tasks = set() 16 | 17 | def __getattr__(self, name): 18 | return getattr(self.response, name) 19 | 20 | @property 21 | def protocol(self): # don't cache request.protocol 22 | return self.response.request.protocol 23 | 24 | def create_task(self, coro): 25 | task = self.loop.create_task(coro) 26 | 27 | self.tasks.add(task) 28 | task.add_done_callback(self.tasks.discard) 29 | 30 | async def join(self): 31 | while self.tasks: 32 | await self.tasks.pop() 33 | 34 | async def handle_exception(self, exc): 35 | if self.protocol is None or self.protocol.transport is None: 36 | return 37 | 38 | if self.protocol.transport.is_closing(): # maybe stuck? 39 | self.protocol.transport.abort() 40 | return 41 | 42 | if self.response.request.upgraded: 43 | await self.response.handle_exception(exc) 44 | else: 45 | self.response.request.http_keepalive = False 46 | 47 | if isinstance(exc, Exception): 48 | if not self.response.headers_sent(): 49 | self.response.set_status(500, b'Internal Server Error') 50 | self.response.set_content_type(b'text/html; charset=utf-8') 51 | 52 | if self.protocol.options['debug']: 53 | te = TracebackException.from_exception(exc) 54 | await self.response.write( 55 | b'Hello, World!
') 177 | 178 | def test_static_file(self): 179 | header, body = getcontents(host=HTTP_HOST, 180 | port=HTTP_PORT, 181 | method='GET', 182 | url='/static/hello.gif', 183 | version='1.1') 184 | 185 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 186 | self.assertTrue(b'\r\nContent-Type: image/gif' in header) 187 | self.assertEqual(body[:6], b'GIF89a') 188 | 189 | def test_badrequest(self): 190 | header, body = getcontents( 191 | host=HTTP_HOST, 192 | port=HTTP_PORT, 193 | raw=b'GET HTTP/\r\nHost: localhost:%d\r\n\r\n' % HTTP_PORT 194 | ) 195 | 196 | self.assertEqual( 197 | header[:header.find(b'\r\n')], 198 | b'HTTP/1.1 400 Bad Request' 199 | ) 200 | 201 | def test_private_file(self): 202 | header, body = getcontents(host=HTTP_HOST, 203 | port=HTTP_PORT, 204 | method='GET', 205 | url='/__globals__.py', 206 | version='1.1') 207 | 208 | self.assertEqual( 209 | header[:header.find(b'\r\n')], 210 | b'HTTP/1.1 404 Not Found' 211 | ) 212 | 213 | def test_sec_path_traversal(self): 214 | header, body = getcontents(host=HTTP_HOST, 215 | port=HTTP_PORT, 216 | method='GET', 217 | url='../.ssh/id_rsa', 218 | version='1.1') 219 | 220 | self.assertEqual( 221 | header[:header.find(b'\r\n')], 222 | b'HTTP/1.1 403 Forbidden' 223 | ) 224 | self.assertEqual(body, b'Path traversal is not allowed') 225 | 226 | def test_sec_dotfiles(self): 227 | header, body = getcontents(host=HTTP_HOST, 228 | port=HTTP_PORT, 229 | method='GET', 230 | url='/.env', 231 | version='1.1') 232 | 233 | self.assertEqual( 234 | header[:header.find(b'\r\n')], 235 | b'HTTP/1.1 403 Forbidden' 236 | ) 237 | self.assertEqual(body, b'Access to dotfiles is prohibited') 238 | 239 | def test_sec_long_path(self): 240 | header, body = getcontents(host=HTTP_HOST, 241 | port=HTTP_PORT, 242 | method='GET', 243 | url='/q' * 128, 244 | version='1.1') 245 | 246 | self.assertEqual( 247 | header[:header.find(b'\r\n')], 248 | b'HTTP/1.1 403 Forbidden' 249 | ) 250 | self.assertEqual(body, b'Unsafe URL detected') 251 | 252 | def test_sec_unsafe_chars_percent(self): 253 | header, body = getcontents(host=HTTP_HOST, 254 | port=HTTP_PORT, 255 | method='GET', 256 | url='/example.php%00.png', 257 | version='1.1') 258 | 259 | self.assertEqual( 260 | header[:header.find(b'\r\n')], 261 | b'HTTP/1.1 403 Forbidden' 262 | ) 263 | self.assertEqual(body, b'Unsafe URL detected') 264 | 265 | def test_sec_unsafe_chars_nul(self): 266 | header, body = getcontents(host=HTTP_HOST, 267 | port=HTTP_PORT, 268 | method='GET', 269 | url='/example.php\x00.png', 270 | version='1.1') 271 | 272 | # NUL is already handled by upstream 273 | self.assertEqual( 274 | header[:header.find(b'\r\n')], 275 | b'HTTP/1.0 400 Bad Request' 276 | ) 277 | 278 | def test_disallowed_ext(self): 279 | header, body = getcontents(host=HTTP_HOST, 280 | port=HTTP_PORT, 281 | method='GET', 282 | url='/bad.ext', 283 | version='1.1') 284 | 285 | self.assertEqual( 286 | header[:header.find(b'\r\n')], 287 | b'HTTP/1.1 403 Forbidden' 288 | ) 289 | self.assertEqual(body, b'Disallowed file extension: .ext') 290 | 291 | 292 | if __name__ == '__main__': 293 | main() 294 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | __all__ = ('read_header', 'getcontents') 2 | 3 | import socket # noqa: E402 4 | import time # noqa: E402 5 | 6 | 7 | def read_header(header, key): 8 | name = b'\r\n%s: ' % key 9 | values = [] 10 | start = 0 11 | 12 | while True: 13 | start = header.find(name, start) 14 | 15 | if start == -1: 16 | break 17 | 18 | start += len(name) 19 | values.append(header[start:header.find(b'\r\n', start)]) 20 | 21 | return values 22 | 23 | 24 | # a simple HTTP client for tests 25 | def getcontents(host, port, method='GET', url='/', version='1.1', headers=(), 26 | data='', raw=b'', timeout=10, max_retries=10): 27 | if max_retries <= 0: 28 | raise ValueError('max_retries is exceeded, or it cannot be negative') 29 | 30 | method = method.upper().encode('latin-1') 31 | url = url.encode('latin-1') 32 | version = version.encode('latin-1') 33 | 34 | if raw == b'': 35 | headers = list(headers) 36 | 37 | if data: 38 | if not headers: 39 | headers.append( 40 | 'Content-Type: application/x-www-form-urlencoded' 41 | ) 42 | 43 | headers.append('Content-Length: %d' % len(data)) 44 | 45 | raw = b'%s %s HTTP/%s\r\nHost: %s:%d\r\n%s\r\n\r\n%s' % ( 46 | method, url, version, host.encode('latin-1'), port, 47 | '\r\n'.join(headers).encode('latin-1'), data.encode('latin-1') 48 | ) 49 | 50 | family = socket.AF_INET 51 | 52 | if ':' in host: 53 | family = socket.AF_INET6 54 | 55 | if host in ('0.0.0.0', '::'): 56 | host = 'localhost' 57 | 58 | with socket.socket(family, socket.SOCK_STREAM) as sock: 59 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 60 | sock.settimeout(timeout) 61 | 62 | while sock.connect_ex((host, port)) != 0: # server is not ready yet? 63 | print('getcontents: reconnecting: %s:%d' % (host, port)) 64 | time.sleep(1) 65 | 66 | request_header = raw[:raw.find(b'\r\n\r\n') + 4] 67 | request_body = raw[raw.find(b'\r\n\r\n') + 4:] 68 | 69 | try: 70 | sock.sendall(request_header) 71 | 72 | if not (b'\r\nExpect: 100-continue' in request_header or 73 | b'\r\nUpgrade:' in request_header): 74 | sock.sendall(request_body) 75 | 76 | response_data = bytearray() 77 | response_header = b'' 78 | content_length = -1 79 | 80 | while True: 81 | if ((content_length != -1 and 82 | len(response_data) >= content_length) or 83 | response_data.endswith(b'\r\n0\r\n\r\n')): 84 | break 85 | 86 | buf = sock.recv(4096) 87 | 88 | if not buf: 89 | break 90 | 91 | response_data.extend(buf) 92 | 93 | if response_header: 94 | continue 95 | 96 | header_size = response_data.find(b'\r\n\r\n') 97 | 98 | if header_size == -1: 99 | continue 100 | 101 | response_header = response_data[:header_size] 102 | del response_data[:header_size + 4] 103 | 104 | if method == b'HEAD': 105 | break 106 | 107 | values = read_header(response_header, b'Content-Length') 108 | 109 | if values: 110 | content_length = int(values[0]) 111 | 112 | if response_header.startswith(b'HTTP/%s 100 ' % version): 113 | sock.sendall(request_body) 114 | response_header = b'' 115 | elif response_header.startswith(b'HTTP/%s 101 ' % version): 116 | sock.sendall(request_body) 117 | 118 | return response_header, bytes(response_data) 119 | except OSError: # retry if either sendall() or recv() fails 120 | print( 121 | 'getcontents: retry (%d): %s' % (max_retries, request_header) 122 | ) 123 | time.sleep(1) 124 | return getcontents( 125 | host, port, raw=raw, max_retries=max_retries - 1 126 | ) 127 | --------------------------------------------------------------------------------