├── .github
└── workflows
│ ├── docs-publish.yaml
│ ├── pypi-publish.yaml
│ └── pytest-cover-run.yaml
├── .gitignore
├── .pylintrc
├── LICENSE
├── README.md
├── docs
├── Makefile
├── make.bat
├── requirements.txt
└── source
│ ├── conf.py
│ ├── contents.rst
│ ├── docs
│ ├── internals.rst
│ └── trusting-mitm.rst
│ ├── index.rst
│ ├── introduction
│ ├── how-mitm-works.rst
│ └── quickstart.rst
│ └── module
│ ├── core.rst
│ ├── crypto.rst
│ ├── extension.rst
│ └── mitm.rst
├── mitm
├── __init__.py
├── core.py
├── crypto.py
├── extension
│ ├── __init__.py
│ ├── middleware.py
│ └── protocol.py
└── mitm.py
├── pyproject.toml
├── requirements.txt
├── setup.cfg
├── setup.py
└── tests
├── __init__.py
├── conftest.py
├── extension
├── __init__.py
├── test_middleware.py
└── test_protocol.py
├── requirements.txt
├── test_core.py
├── test_crypto.py
└── test_mitm.py
/.github/workflows/docs-publish.yaml:
--------------------------------------------------------------------------------
1 | name: Docs
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | docs:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checks out repo
13 | uses: actions/checkout@v1
14 |
15 | - name: Generates HTML documentation
16 | uses: synchronizing/sphinx-action@master
17 | with:
18 | pre-build-command: "apt-get update -y && apt-get install -y git"
19 | docs-folder: "docs/"
20 |
21 | - name: Saves the HTML build documentation
22 | uses: actions/upload-artifact@v4
23 | with:
24 | path: docs/build/html/
25 |
26 | - name: Commits docs changes to gh-pages branch
27 | run: |
28 | # Copies documentation outside of git folder.
29 | mkdir -p ../docs/html
30 | cp -r docs/build/html ../docs/
31 | # Checks out to gh-pages branch.
32 | git checkout -b gh-pages
33 | # Copies files to branch.
34 | cp -r ../docs/html/* .
35 | # Sets up no Jekyll config.
36 | touch .nojekyll
37 | # Commits the changes.
38 | git config --local user.email "action@github.com"
39 | git config --local user.name "GitHub Action"
40 | git add .
41 | git commit -m "Documentation update." -a || true
42 |
43 | - name: Push changes to gh-pages branch
44 | uses: ad-m/github-push-action@master
45 | with:
46 | branch: gh-pages
47 | github_token: ${{ secrets.GITHUB_TOKEN }}
48 | force: True
49 |
--------------------------------------------------------------------------------
/.github/workflows/pypi-publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish to PyPi
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | jobs:
9 | pypi:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checks repo out
13 | uses: actions/checkout@v2
14 | - name: Sets up Python
15 | uses: actions/setup-python@v2
16 | with:
17 | python-version: '3.x'
18 | - name: Install depedencies
19 | run: |
20 | python -m pip install --upgrade pip
21 | pip install setuptools wheel twine .
22 | - name: Builds and publishes to PyPi
23 | env:
24 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
26 | run: |
27 | python setup.py sdist bdist_wheel
28 | twine upload dist/*
29 |
--------------------------------------------------------------------------------
/.github/workflows/pytest-cover-run.yaml:
--------------------------------------------------------------------------------
1 | # Pytest + Coveralls
2 | name: Build
3 |
4 | on:
5 | push:
6 | tags:
7 | - '*'
8 | branches:
9 | - master
10 | pull_request:
11 | branches:
12 | - '*'
13 |
14 | jobs:
15 | pytest:
16 | runs-on: ubuntu-latest
17 | strategy:
18 | matrix:
19 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
20 | steps:
21 | - name: Checks repo out
22 | uses: actions/checkout@v2
23 | - name: Set up Python ${{ matrix.python-version }}
24 | uses: actions/setup-python@v2
25 | with:
26 | python-version: ${{ matrix.python-version }}
27 | - name: Install testing depedencies
28 | run: |
29 | python -m pip install --upgrade pip setuptools wheel
30 | pip install .
31 | pip install -r tests/requirements.txt
32 | - name: Run PyTest
33 | run: |
34 | pytest --cov=mitm tests/
35 | - name: Run Coveralls
36 | env:
37 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39 | run: |
40 | coveralls
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Env
2 | .DS_Store
3 | .ropeproject
4 | .python-version
5 | .vscode
6 | openssl
7 | .venv
8 |
9 | # Python
10 | __pycache__
11 | *.egg-info
12 | .eggs
13 |
14 | # Documentation
15 | docs/build/
16 |
17 | # PyTest + Coverage
18 | .pytest_cache
19 | .coverage
20 | test.py
21 |
--------------------------------------------------------------------------------
/.pylintrc:
--------------------------------------------------------------------------------
1 | [MESSAGES CONTROL]
2 | disable = logging-fstring-interpolation, too-many-arguments, cyclic-import
3 |
4 | [FORMAT]
5 | max-line-length = 120
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Felipe Faria
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 👨💻 mitm
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | A customizable man-in-the-middle TCP proxy with support for HTTP & HTTPS.
26 |
27 | ## Installing
28 |
29 | ```
30 | pip install mitm
31 | ```
32 |
33 | Note that OpenSSL 1.1.1 or greater is required.
34 |
35 | ## Documentation
36 |
37 | Documentation can be found [**here**](https://synchronizing.github.io/mitm/).
38 |
39 | ## Using
40 |
41 | Using the default values for the `MITM` class:
42 |
43 | ```python
44 | from mitm import MITM, protocol, middleware, crypto
45 |
46 | mitm = MITM(
47 | host="127.0.0.1",
48 | port=8888,
49 | protocols=[protocol.HTTP],
50 | middlewares=[middleware.Log], # middleware.HTTPLog used for the example below.
51 | certificate_authority = crypto.CertificateAuthority()
52 | )
53 | mitm.run()
54 | ```
55 |
56 | This will start a proxy on port `8888` that is capable of intercepting all HTTP traffic (with support for SSL/TLS) and log all activity.
57 |
58 | ## Extensions
59 |
60 | `mitm` can be customized through the implementations of middlewares and protocols.
61 |
62 | [Middlewares](https://synchronizing.github.io/mitm/docs/internals.html#mitm.core.Middleware) are event-driven hooks that are called when connections are made, requests are sent, responses are received, and connections are closed.
63 |
64 | [Protocols](https://synchronizing.github.io/mitm/docs/internals.html#mitm.core.Protocol) are implementations on _how_ the data flows between the client and server, and is used to implement [application layer](https://en.wikipedia.org/wiki/Application_layer) protocols and/or more complex extensions.
65 |
66 | ## Example
67 |
68 | Using the example above we can send a request to the server via another script:
69 |
70 | ```python
71 | import requests
72 |
73 | proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"}
74 | requests.get("https://httpbin.org/anything", proxies=proxies, verify=False)
75 | ```
76 |
77 | Which will lead to the following being logged where `mitm` is running in:
78 |
79 | ```
80 | 2022-06-08 15:07:10 INFO MITM server started on 127.0.0.1:8888.
81 | 2022-06-08 15:07:11 INFO Client 127.0.0.1:64638 has connected.
82 | 2022-06-08 15:07:11 INFO Client 127.0.0.1:64638 to mitm:
83 |
84 | → CONNECT httpbin.org:443 HTTP/1.0
85 |
86 | 2022-06-08 15:07:12 INFO Client 127.0.0.1:64638 has connected to server 34.206.80.189:443.
87 | 2022-06-08 15:07:12 INFO Client 127.0.0.1:64638 to 34.206.80.189:443:
88 |
89 | → GET /anything HTTP/1.1
90 | → Host: httpbin.org
91 | → User-Agent: python-requests/2.26.0
92 | → Accept-Encoding: gzip, deflate
93 | → Accept: */*
94 | → Connection: keep-alive
95 |
96 | 2022-06-08 15:07:12 INFO Server 34.206.80.189:443 to client 127.0.0.1:64638:
97 |
98 | ← HTTP/1.1 200 OK
99 | ← Date: Wed, 08 Jun 2022 19:07:12 GMT
100 | ← Content-Type: application/json
101 | ← Content-Length: 396
102 | ← Connection: keep-alive
103 | ← Server: gunicorn/19.9.0
104 | ← Access-Control-Allow-Origin: *
105 | ← Access-Control-Allow-Credentials: true
106 | ←
107 | ← {
108 | ← "args": {},
109 | ← "data": "",
110 | ← "files": {},
111 | ← "form": {},
112 | ← "headers": {
113 | ← "Accept": "*/*",
114 | ← "Accept-Encoding": "gzip, deflate",
115 | ← "Host": "httpbin.org",
116 | ← "User-Agent": "python-requests/2.26.0",
117 | ← "X-Amzn-Trace-Id": "Root=1-62a0f360-774052c80b60f4ea049f5665"
118 | ← },
119 | ← "json": null,
120 | ← "method": "GET",
121 | ← "origin": "xxx.xxx.xxx.xxx",
122 | ← "url": "https://httpbin.org/anything"
123 | ← }
124 |
125 | 2022-06-08 15:07:27 INFO Server 34.206.80.189:443 has disconnected.
126 | 2022-06-08 15:07:27 INFO Client 127.0.0.1:64638 has disconnected.
127 | ```
128 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | # Adds serve ability with auto-reload.
16 | serve:
17 | @sphinx-autobuild -b html source build/html
18 |
19 | .PHONY: help Makefile
20 |
21 | # Catch-all target: route all unknown targets to Sphinx using the new
22 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
23 | %: Makefile
24 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
25 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx
2 | sphinx-copybutton
3 | sphinx-autobuild
4 | furo
5 | git+https://github.com/synchronizing/sphinx-autodoc-typehints.git
6 | git+https://github.com/synchronizing/mitm.git
7 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | import os
10 | import sys
11 |
12 | # Adds system path two folders back (where project lives.)
13 | sys.path.insert(0, os.path.abspath("../.."))
14 |
15 | # PyLint might complain, but the interpreter should be able to find this on run.
16 | from mitm import *
17 |
18 | # -- Project information -----------------------------------------------------
19 |
20 | project = "👨💻 mitm"
21 | copyright = "2021, Felipe Faria"
22 | author = "Felipe Faria"
23 |
24 | # -- General configuration ---------------------------------------------------
25 |
26 | # Project Name
27 | html_title = "{}".format(project)
28 |
29 | # Order of docs.
30 | autodoc_member_order = "bysource"
31 |
32 | # Turn off typehints.
33 | autodoc_typehints = "none"
34 |
35 | # Remove module names from class docs.
36 | add_module_names = False
37 |
38 | # Show only class docs.
39 | autoclass_content = "class"
40 |
41 | # List __init___ docstrings separately from the class docstring
42 | napoleon_include_init_with_doc = True
43 |
44 | # Removes the default values from the documentation.
45 | keep_default_values = False
46 |
47 | # Removes the class values; e.g. 'Class(val, val, val):' becomes 'Class:'.
48 | hide_class_values = True
49 |
50 | # Test
51 | default_role = "py:obj"
52 |
53 | # Add any Sphinx extension module names here, as strings. They can be
54 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
55 | # ones.
56 | extensions = [
57 | "sphinx.ext.viewcode",
58 | "sphinx.ext.napoleon",
59 | "sphinx_autodoc_typehints",
60 | "sphinx.ext.autodoc",
61 | "sphinx_copybutton",
62 | ]
63 |
64 | # Add any paths that contain templates here, relative to this directory.
65 | templates_path = ["_templates"]
66 |
67 | # List of patterns, relative to source directory, that match files and
68 | # directories to ignore when looking for source files.
69 | # This pattern also affects html_static_path and html_extra_path.
70 | exclude_patterns = []
71 |
72 | # -- Options for HTML output -------------------------------------------------
73 |
74 | # The theme to use for HTML and HTML Help pages. See the documentation for
75 | # a list of builtin themes.
76 | html_theme = "furo"
77 |
78 | html_theme_options = {}
79 |
80 | # Add any paths that contain custom static files (such as style sheets) here,
81 | # relative to this directory. They are copied after the builtin static files,
82 | # so a file named "default.css" will overwrite the builtin "default.css".
83 | html_static_path = ["_static"]
84 |
--------------------------------------------------------------------------------
/docs/source/contents.rst:
--------------------------------------------------------------------------------
1 | ########
2 | Contents
3 | ########
4 |
5 | .. toctree::
6 | :caption: Introduction
7 |
8 | introduction/quickstart
9 | introduction/how-mitm-works
10 |
11 | .. toctree::
12 | :caption: Docs
13 |
14 | docs/trusting-mitm
15 | docs/internals
16 |
17 | .. toctree::
18 | :caption: API
19 | :glob:
20 |
21 | module/core
22 | module/crypto
23 | module/extension
24 | module/mitm
25 |
--------------------------------------------------------------------------------
/docs/source/docs/internals.rst:
--------------------------------------------------------------------------------
1 | ###################
2 | Internals of `mitm`
3 | ###################
4 |
5 | Quick guide on the internal structure of `mitm`.
6 |
7 | Make sure to check out the documentation for `asyncio streams `_ before reading this section.
8 |
9 | ----
10 |
11 | Core
12 | ****
13 |
14 | .. class:: mitm.core.Host
15 |
16 | A host is a pair of `asyncio.StreamReader` and `asyncio.StreamWriter` objects that are used to communicate with the remote host. There are two types of hosts: a client, and a server. A client host is one that is connected to the `mitm`, and a server host is one that the `mitm` connected to on behalf of the client.
17 |
18 | .. attribute:: reader
19 |
20 | The `asyncio.StreamReader` object for the host.
21 |
22 | .. attribute:: writer
23 |
24 | The `asyncio.StreamWriter` object for the host.
25 |
26 | .. attribute:: mitm_managed
27 |
28 | The `mitm_managed` attribute is used to determine whether the `mitm` is responsible for closing the connection with the host. If `mitm_managed` is True, the `mitm` will close the connection with the host when it is done with it. If `mitm_managed` is set to False, the `mitm` will not close the connection with the host, and instead, the developer must close the connection with the host manually. This is useful for situations where the `mitm` is running as a seperate utility and the developer wants to keep the connection open with the host after the `mitm` is done with it.
29 |
30 | .. attribute:: ip
31 |
32 | The IP address of the host.
33 |
34 | .. attribute:: port
35 |
36 | The port of the host.
37 |
38 | `Host` is a `dataclass `_ that is defined like so:
39 |
40 | .. code-block:: python
41 |
42 | @dataclass
43 | class Host:
44 | reader: Optional[asyncio.StreamReader] = None
45 | writer: Optional[asyncio.StreamWriter] = None
46 | mitm_managed: Optional[bool] = True
47 |
48 | .. class:: mitm.core.Connection
49 |
50 | A connection is a pair of `Host` objects that the `mitm` relays data between. When a connection is created the server host is not resolved until the data is intercepted and the protocol and destination server is figured out.
51 |
52 | .. attribute:: client
53 |
54 | The client `mitm.core.Host`. The client host connects to the `mitm`.
55 |
56 | .. attribute:: server
57 |
58 | The server `mitm.core.Host`. A server host is connected to the `mitm` on behalf of the client host.
59 |
60 | .. attribute:: protocol
61 |
62 | The `mitm.core.Protocol` object for the connection.
63 |
64 | `Connection` is a `dataclass `_ that is defined like so:
65 |
66 | .. code-block:: python
67 |
68 | @dataclass
69 | class Connection:
70 | client: Host
71 | server: Host
72 | protocol: Optional[Protocol] = None
73 |
74 | ----
75 |
76 | Extensions
77 | **********
78 |
79 | .. class:: mitm.core.Middleware
80 |
81 | Event-driven hook extension for the `mitm`.
82 |
83 | A middleware is a class that is used to extend the `mitm` framework by allowing event-driven hooks to be added to the `mitm` and executed when the appropriate event occurs. Built-in middlewares can be found in the `mitm.middleware` module.
84 |
85 | .. method:: mitm_started(host: str, port: int)
86 | :async:
87 | :staticmethod:
88 |
89 | Called when the `mitm` server boots-up.
90 |
91 | .. method:: client_connected(connection: Connection)
92 | :async:
93 | :staticmethod:
94 |
95 | Called when a client connects to the `mitm` server. Note that the `mitm.core.Connection` object is not fully initialized yet, and only contains a valid client `mitm.core.Host`.
96 |
97 | .. method:: server_connected(connection: Connection)
98 | :async:
99 | :staticmethod:
100 |
101 | Called when the `mitm` connects with the destination server. At this point the `mitm.core.Connection` object is fully initialized.
102 |
103 | .. method:: client_data(connection: Connection, data: bytes) -> bytes
104 | :async:
105 | :staticmethod:
106 |
107 | Raw TLS/SSL handshake is not sent through this method. Everything should be decrypted beforehand.
108 |
109 | Note:
110 | This method **must** return back data. Modified or not.
111 |
112 | .. method:: server_data(connection: Connection, data: bytes) -> bytes
113 | :async:
114 | :staticmethod:
115 |
116 | Called when the `mitm` receives data from the destination server. Data that comes through this hook can be modified and returned to the `mitm` as new data to be sent to the client.
117 |
118 | Note:
119 | This method **must** return back data. Modified or not.
120 |
121 | .. method:: client_disconnected(connection: Connection)
122 | :async:
123 | :staticmethod:
124 |
125 | Called when the client disconnects.
126 |
127 | .. method:: server_disconnected(connection: Connection)
128 | :async:
129 | :staticmethod:
130 |
131 | Called when the server disconnects.
132 |
133 | .. class:: mitm.core.Protocol
134 |
135 | Protocols are implementations on how the data flows between the client and server. Application-layer protocols are implemented by subclassing this class. Built-in protocols can be found in the `mitm.extension` package.
136 |
137 | .. attribute:: bytes_needed
138 |
139 | Specifies how many bytes are needed to determine the protocol.
140 |
141 | .. attribute:: buffer_size
142 |
143 | The size of the buffer to use when reading data.
144 |
145 | .. attribute:: timeout
146 |
147 | The timeout to use when reading data.
148 |
149 | .. attribute:: keep_alive
150 |
151 | Whether or not to keep the connection alive.
152 |
153 | Note that the attributes above must be set per-protocol basis.
154 |
155 |
156 | .. method:: resolve(connection: Connection, data: bytes) -> Tuple[str, int, bool]
157 | :async:
158 |
159 | Resolves the destination of the connection. Returns a tuple containing the host, port, and bool that indicates if the connection is encrypted.
160 |
161 | .. method:: connect(connection: Connection, host: str, port: int, data: bytes)
162 | :async:
163 |
164 | Attempts to connect to destination server using the given data. Returns `True` if the connection was successful, raises `InvalidProtocol` if the connection failed.
165 |
166 | .. method:: handle(connection: Connection)
167 | :async:
168 |
169 | Handles the connection between a client and a server.
170 |
--------------------------------------------------------------------------------
/docs/source/docs/trusting-mitm.rst:
--------------------------------------------------------------------------------
1 | ###############
2 | Trusting `mitm`
3 | ###############
4 |
5 | When `mitm` runs it generates a `certificate authority `_ (CA) that is used to generate dummy certificates for each website visited. To trust this certificate you must install it on your system. By default, the certificate is generated in the `mitm.__data__` directory. To discover what this directory is do the following:
6 |
7 | .. code-block:: shell
8 |
9 | $ python
10 | Python 3.9.6 (default, Aug 31 2021, 00:19:31)
11 | [Clang 12.0.5 (clang-1205.0.22.11)] on darwin
12 | Type "help", "copyright", "credits" or "license" for more information.
13 | >>> import mitm
14 | >>> mitm.__data__
15 | PosixPath('/Users/felipe/Library/Application Support/mitm') # You should see a different path.
16 | >>> exit()
17 |
18 | $ ls /Users/felipe/Library/Application\ Support/mitm
19 | mitm.key mitm.pem
20 |
21 | Customizing Path
22 | ----------------
23 |
24 | To customize the path where the certificate is generated, you can use the following snippet:
25 |
26 | .. code-block:: python
27 |
28 | from mitm import MITM, CertificateAuthority, middleware, protocol
29 | from pathlib import Path
30 |
31 | # Loads the CA certificate.
32 | path = Path("/Users/felipe/Desktop")
33 | certificate_authority = CertificateAuthority.init(path=path)
34 |
35 | # Starts the MITM server.
36 | mitm = MITM(
37 | host="127.0.0.1",
38 | port=8888,
39 | protocols=[protocol.HTTP],
40 | middlewares=[middleware.Log],
41 | certificate_authority=certificate_authority,
42 | )
43 | mitm.run()
44 |
45 | Installing CA
46 | --------------
47 |
48 | Different systems have different ways to install the certificate. Its recommended you look up "how to install a certificate" on your system.
49 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | .. include:: contents.rst
2 |
--------------------------------------------------------------------------------
/docs/source/introduction/how-mitm-works.rst:
--------------------------------------------------------------------------------
1 | ##############
2 | How mitm works
3 | ##############
4 |
5 | A high-level overview on how a man-in-the-middle proxy works.
6 |
7 | ----
8 |
9 | `mitm` is a TCP proxy server that is capable of intercepting requests and responses going through it.
10 |
11 | To understand how an mitm proxy works let's take a look at a simple example using the HTTP protocol. Let's familiarize ourselves with a raw HTTP communication, how a normal proxy functions, and finally how an MITM proxy works.
12 |
13 | ----
14 |
15 | HTTP & HTTPS
16 | ------------
17 |
18 | For the sake of example, imagine a client is trying to reach `example.com`:
19 |
20 | .. code-block:: python
21 |
22 | import requests
23 | requests.get("http://example.com")
24 |
25 | Whether the client is trying to reach the domain via the `requests` module or their browser, both methods must generate a valid HTTP request to send to the server. In the case of the above, `requests` would generate the following HTTP request:
26 |
27 | .. code-block::
28 |
29 | GET http://example.com/ HTTP/1.1
30 | Host: example.com
31 | User-Agent: python-requests/2.26.0
32 | Accept-Encoding: gzip, deflate
33 | Accept: */*
34 | Connection: keep-alive
35 |
36 | This HTTP request is sent through hundreds of miles of wires until it reaches the server, which interprets the message, and replies back with the requested page:
37 |
38 | .. code-block::
39 |
40 | HTTP/1.1 200 OK
41 | Content-Encoding: gzip
42 | Accept-Ranges: bytes
43 | Age: 111818
44 | Cache-Control: max-age=604800
45 | Content-Type: text/html; charset=UTF-8
46 | Date: Fri, 05 Nov 2021 18:49:47 GMT
47 | Etag: "3147526947"
48 | Expires: Fri, 12 Nov 2021 18:49:47 GMT
49 | Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
50 | Server: ECS (agb/5295)
51 | Vary: Accept-Encoding
52 | X-Cache: HIT
53 | Content-Length: 648
54 | [data]
55 |
56 | The server, like the client, strictly follows the RFCs that define the `HTTP `_ protocol. In some cases, however, the client might want to create a more secure connection with the server. We know of this as HTTPS, which stands for HTTP secure. To do this, a client would connect to the server with the `https` prefix:
57 |
58 | .. code-block:: python
59 |
60 | import requests
61 | requests.get("https://example.com")
62 |
63 | In this case, the clients initial request will be
64 |
65 | .. code-block::
66 |
67 | CONNECT example.com:443 HTTP/1.0
68 |
69 | Which indicates that the client is ready to begin a secure connection with the server. When the server receives this message it replies back to the client
70 |
71 | .. code-block::
72 |
73 | HTTP/1.1 200 OK
74 |
75 | And the client begins what is called the "TLS/SSL handshake," which you can read more about it `here `_. During this handshake the server and the client create a secure tunnel that they can communicate without fear of someone being able to see their communication.
76 |
77 | All of the above is important to have a general understanding of to comprehend how proxies work.
78 |
79 | ----
80 |
81 | Proxies
82 | -------
83 |
84 | A proxy works by sitting between the client and destination server and is typically used to concel the IP address of a client. A normal proxy would be used either by setting its settings in the browsers' configuration, or via the `proxies` parameter in requests:
85 |
86 | .. code-block:: python
87 |
88 | import requests
89 |
90 | proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"}
91 | requests.get("http://example.com", proxies=proxies)
92 |
93 | In this case `requests` will generate the same HTTP request we saw above, but instead of sending it to the destination server - `example.com` - it will send it to the proxy.
94 |
95 | .. code-block::
96 |
97 | GET http://example.com/ HTTP/1.1
98 | Host: example.com
99 | User-Agent: python-requests/2.26.0
100 | Accept-Encoding: gzip, deflate
101 | Accept: */*
102 | Connection: keep-alive
103 |
104 | The proxy, once it receives the HTTP request, interprets *where* the client is trying to go via either the first line of the request, or the ``Host`` header. It then opens a connection with the destination server on behalf of the client, and allows the client and the server to communicate between each other through *it*. In other words, a proxy is a 'man in the middle' whose job is primairly concentrated on conceling the IP address of the client.
105 |
106 | When a client utilises HTTPS (``https://``) the initial request goes to the proxy, and subsequently the proxy connects the client and server. The difference here, however, is that after the client and server are connected they perform the TLS/SSL handshake and begin a secure connection. This connection is now encrypted and the client and server can communicate freely without fear of being intercepted.
107 |
108 | ----
109 |
110 | Man-in-the-middle
111 | -----------------
112 |
113 | `mitm`, therefore, is a proxy that purposely intercepts the requests and responses going through it. When a client connection is a standard HTTP connection the `mitm` server doesn't have to do anything special. It creates a connection to the destination server on behalf of the client and listens to the messages between both. The issue is when a client is trying to connect to the server via HTTPS:
114 |
115 | .. code-block:: python
116 |
117 | import requests
118 |
119 | proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"}
120 | requests.get("https://example.com", proxies=proxies)
121 |
122 | When this happens, and the client sends a
123 |
124 | .. code-block:: python
125 |
126 | CONNECT example.com:443 HTTP/1.0
127 |
128 | What `mitm` does is *acts* like the destination server by responding back to the client
129 |
130 | .. code-block::
131 |
132 | HTTP/1.1 200 OK
133 |
134 | and then performs the TLS/SSL handshake on behalf of the destination server. Once `mitm` and the client are connected it then opens a connection with the destination server and relays their communication back-and-forth, sitting in the middle and listening. Note, however, that since `mitm` generates its own TLS/SSL certificates a client will not trust it unless one either adds the generated certificate to their keychain (**not recommended**) or one uses a special flag in `requests`:
135 |
136 | .. code-block:: python
137 |
138 | import requests
139 |
140 | proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"}
141 | requests.get("https://example.com", proxies=proxies, verify=False)
142 |
143 | ... and that's really it!
144 |
--------------------------------------------------------------------------------
/docs/source/introduction/quickstart.rst:
--------------------------------------------------------------------------------
1 | ##########
2 | Quickstart
3 | ##########
4 |
5 | A customizable man-in-the-middle TCP proxy with support for HTTP & HTTPS.
6 |
7 | ----
8 |
9 | Installing
10 | ----------
11 |
12 | .. code-block::
13 |
14 | pip install mitm
15 |
16 | Note that OpenSSL 1.1.1 or greater is required.
17 |
18 | Using
19 | -----
20 |
21 | Using the default values for the :py:class:`mitm.MITM` class:
22 |
23 | .. code-block:: python
24 |
25 | from mitm import MITM, protocol, middleware, crypto
26 |
27 | mitm = MITM(
28 | host="127.0.0.1",
29 | port=8888,
30 | protocols=[protocol.HTTP],
31 | middlewares=[middleware.HTTPLog],
32 | certificate_authority = crypto.CertificateAuthority()
33 | )
34 | mitm.run()
35 |
36 | This will start a proxy on port `8888` that is capable of intercepting all HTTP traffic (with support for SSL/TLS) and log all activity.
37 |
38 | Questions & Answers
39 | --------------------
40 |
41 | **How does this project differ from** ``mitmproxy`` **?**
42 |
43 | Purpose, implementation, and customization style. The purpose of mitm is to be a light-weight customizable man-in-the-middle proxy intended for larger projects. ``mitmproxy`` (with its beautiful CLI) seems to be more for interactive request and response tampering and capturing. While it does support everything ``mitm`` does plus more, it lacks the simplicity that mitm has.
44 |
45 | **What protocols are supported out-of-the-box?**
46 |
47 | Only HTTP/1.0 and HTTP/1.1 are supported. Any other protocol (FTP, SMTP, etc.) will require a custom implementation. Any protocol that is built on top of HTTP/1.1 (e.g. websockets) should in theory work.
48 |
--------------------------------------------------------------------------------
/docs/source/module/core.rst:
--------------------------------------------------------------------------------
1 | ####
2 | core
3 | ####
4 |
5 | .. code-block:: python
6 |
7 | from mitm import core
8 |
9 | .. automodule:: mitm.core
10 |
11 | -----
12 |
13 | .. autoclass:: mitm.core.Host
14 | :members:
15 |
16 | .. autoclass:: mitm.core.Connection
17 | :members:
18 |
19 | .. autoclass:: mitm.core.Flow(enum.Enum)
20 | :members:
21 |
22 | .. autoclass:: mitm.core.Middleware(abc.ABC)
23 | :members:
24 |
25 | .. autoclass:: mitm.core.InvalidProtocol(Exception)
26 | :members:
27 |
28 | .. autoclass:: mitm.core.Protocol(abc.ABC)
29 | :members:
30 |
--------------------------------------------------------------------------------
/docs/source/module/crypto.rst:
--------------------------------------------------------------------------------
1 | ######
2 | crypto
3 | ######
4 |
5 |
6 | .. code-block:: python
7 |
8 | from mitm import crypto
9 |
10 | .. automodule:: mitm.crypto
11 |
12 | ----
13 |
14 | .. automethod:: mitm.crypto.new_RSA
15 |
16 | .. automethod:: mitm.crypto.new_X509
17 |
18 | ----
19 |
20 | .. autodata:: mitm.crypto.LRU_MAX_SIZE
21 | :annotation:
22 |
23 | .. autoclass:: mitm.crypto.CertificateAuthority
24 | :members:
25 |
--------------------------------------------------------------------------------
/docs/source/module/extension.rst:
--------------------------------------------------------------------------------
1 | #########
2 | extension
3 | #########
4 |
5 | .. code-block:: python
6 |
7 | from mitm import extension
8 |
9 | .. automodule:: mitm.extension
10 |
11 | -----
12 |
13 | Middlewares
14 | ***********
15 |
16 | .. autoclass:: mitm.extension.middleware.Log
17 |
18 | .. autoclass:: mitm.extension.middleware.HTTPLog(Log)
19 |
20 | ----
21 |
22 | Protocols
23 | *********
24 |
25 | .. autoclass:: mitm.extension.protocol.HTTP
26 |
--------------------------------------------------------------------------------
/docs/source/module/mitm.rst:
--------------------------------------------------------------------------------
1 | ####
2 | mitm
3 | ####
4 |
5 | .. code-block:: python
6 |
7 | from mitm import MITM
8 |
9 | .. automodule:: mitm.mitm
10 |
11 | -----
12 |
13 | .. autoclass:: mitm.mitm.MITM
14 |
15 | .. automethod:: mitm.mitm.MITM.__init__
16 | .. automethod:: mitm.mitm.MITM.entry
17 |
18 | The server is started by using `asyncio.start_server `_ function like so:
19 |
20 | .. code-block:: python
21 |
22 | ...
23 | server = await asyncio.start_server(
24 | lambda reader, writer: self.mitm(
25 | Connection(
26 | client=Host(reader=reader, writer=writer),
27 | server=Host(),
28 | )
29 | ),
30 | host=self.host,
31 | port=self.port,
32 | )
33 | ...
34 |
35 | .. automethod:: mitm.mitm.MITM.mitm
36 |
--------------------------------------------------------------------------------
/mitm/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Github: https://github.com/synchronizing/mitm
3 | Docs: https://synchronizing.github.io/mitm/
4 | """
5 |
6 | # pylint: disable=wrong-import-order, wrong-import-position
7 |
8 | __author__ = "Felipe Faria"
9 | __project__ = "mitm"
10 |
11 | import pathlib
12 | import appdirs
13 | from pbr.version import VersionInfo
14 |
15 | __version__ = VersionInfo(__project__).release_string()
16 | __data__ = pathlib.Path(appdirs.user_data_dir(__package__, __author__))
17 |
18 | import logging
19 | import sys
20 |
21 | logging.basicConfig(
22 | stream=sys.stdout,
23 | format="%(asctime)s %(levelname)-8s %(message)s",
24 | datefmt="%Y-%m-%d %H:%M:%S",
25 | level=logging.INFO,
26 | )
27 |
28 | from mitm.core import *
29 | from mitm.crypto import *
30 | from mitm.extension import *
31 | from mitm.mitm import *
32 |
33 | __all__ = [
34 | "Host",
35 | "Connection",
36 | "Flow",
37 | "MITM",
38 | "Middleware",
39 | "Protocol",
40 | "InvalidProtocol",
41 | "CertificateAuthority",
42 | ]
43 |
--------------------------------------------------------------------------------
/mitm/core.py:
--------------------------------------------------------------------------------
1 | """
2 | Core components of the MITM framework.
3 | """
4 |
5 | from __future__ import annotations
6 |
7 | import asyncio
8 | from abc import ABC, abstractmethod
9 | from dataclasses import dataclass
10 | from enum import Enum
11 | from typing import Any, List, Optional, Tuple
12 |
13 | from mitm.crypto import CertificateAuthority
14 |
15 |
16 | @dataclass
17 | class Host:
18 | """
19 | Dataclass representing an `mitm` host.
20 |
21 | A host is a pair of `asyncio.StreamReader` and `asyncio.StreamWriter` objects
22 | that are used to communicate with the remote host. There are two types of hosts: a
23 | client, and a server. A client host is one that is connected to the `mitm`, and a
24 | server host is one that the `mitm` connected to on behalf of the client.
25 |
26 | The `mitm_managed` attribute is used to determine whether the `mitm` is responsible
27 | for closing the connection with the host. If `mitm_managed` is True, the `mitm` will
28 | close the connection with the host when it is done with it. If `mitm_managed` is set
29 | to False, the `mitm` will not close the connection with the host, and instead, the
30 | developer must close the connection with the host manually. This is useful for
31 | situations where the `mitm` is running as a seperate utility and the developer
32 | wants to keep the connection open with the host after the `mitm` is done with it.
33 |
34 | Note:
35 | See more on `dataclasses `_.
36 |
37 | Args:
38 | reader: The reader of the host.
39 | writer: The writer of the host.
40 | mitm_managed: Whether or not the host is managed by the `mitm`.
41 |
42 | Attributes:
43 | reader: The reader of the host.
44 | writer: The writer of the host.
45 | mitm_managed: Whether or not the host is managed by the `mitm`.
46 | ip: The IP address of the host.
47 | port: The port of the host.
48 |
49 | Example:
50 |
51 | .. code-block:: python
52 |
53 | reader, writer = await asyncio.open_connection(...)
54 | server = Host(reader, writer)
55 | """
56 |
57 | reader: Optional[asyncio.StreamReader] = None
58 | writer: Optional[asyncio.StreamWriter] = None
59 | mitm_managed: Optional[bool] = True
60 |
61 | def __post_init__(self):
62 | """
63 | Initializes the host and port information for the Host.
64 |
65 | This method is called by the Dataclass' `__init__` method post-initialization.
66 | """
67 |
68 | # At this point the self.writer is either None or a StreamWriter.
69 | if self.writer:
70 | self.host, self.port = self.writer._transport.get_extra_info("peername") # pylint: disable=protected-access
71 |
72 | def __setattr__(self, name: str, value: Any):
73 | """
74 | Sets Host attributes.
75 |
76 | We hijack this method to set the `host` and `port` attributes if/when the writer
77 | is set. This is because the `host` and `port` attributes are not set until the
78 | writer is set.
79 |
80 | Args:
81 | name: The name of the attribute to set.
82 | value: The value of the attribute to set.
83 | """
84 | if (
85 | name == "writer"
86 | and isinstance(value, asyncio.StreamWriter)
87 | and not getattr(self, "host", None)
88 | and not getattr(self, "port", None)
89 | ):
90 | self.host, self.port = value._transport.get_extra_info("peername")
91 | return super().__setattr__(name, value)
92 |
93 | def __bool__(self) -> bool:
94 | """
95 | Returns whether or not the host is connected.
96 | """
97 | return self.reader is not None and self.writer is not None
98 |
99 | def __repr__(self) -> str: # pragma: no cover
100 | """
101 | Returns a string representation of the host.
102 | """
103 | if self.reader and self.writer:
104 | return f"Host({self.host}:{self.port}, mitm_managed={self.mitm_managed})"
105 |
106 | return f"Host(mitm_managed={self.mitm_managed})"
107 |
108 | def __str__(self) -> str: # pragma: no cover
109 | """
110 | Returns a string representation of the host.
111 | """
112 | if self.reader and self.writer:
113 | return f"{self.host}:{self.port}"
114 |
115 | return ""
116 |
117 |
118 | @dataclass
119 | class Connection:
120 | """
121 | Dataclass representing a standard `mitm` connection.
122 |
123 | A connection is a pair of `Host` objects that the `mitm` relays data between. When a
124 | connection is created the server host is not resolved until the data is intercepted
125 | and the protocol and destination server is figured out.
126 |
127 | Note:
128 | See more on `dataclasses `_.
129 |
130 | Args:
131 | client: The client host.
132 | server: The server host.
133 | protocol: The protocol of the connection.
134 |
135 | Example:
136 | .. code-block:: python
137 |
138 | client = Host(...)
139 | server = Host(...)
140 |
141 | connection = Connection(client, server, Protocol.HTTP)
142 | """
143 |
144 | client: Host
145 | server: Host
146 | protocol: Optional[Protocol] = None
147 |
148 | def __repr__(self) -> str: # pragma: no cover
149 | return f"Connection(client={self.client}, server={self.server}, protocol={self.protocol})"
150 |
151 |
152 | class Flow(Enum):
153 | """
154 | Enum representing the flow of the connection.
155 |
156 | Can be used within the appropriate locations to determine the flow of the
157 | connection. Not used by the core `mitm` framework, but used by the HTTP extension.
158 |
159 | Args:
160 | CLIENT_TO_SERVER: The client is sending data to the server.
161 | SERVER_TO_CLIENT: The server is sending data to the client.
162 | """
163 |
164 | CLIENT_TO_SERVER = 0
165 | SERVER_TO_CLIENT = 1
166 |
167 |
168 | class Middleware(ABC): # pragma: no cover
169 | """
170 | Event-driven hook extension for the `mitm`.
171 |
172 | A middleware is a class that is used to extend the `mitm` framework by allowing
173 | event-driven hooks to be added to the `mitm` and executed when the appropriate
174 | event occurs. Built-in middlewares can be found in the `mitm.middleware` module.
175 | """
176 |
177 | @abstractmethod
178 | async def mitm_started(self, host: str, port: int):
179 | """
180 | Called when the `mitm` server boots-up.
181 | """
182 | raise NotImplementedError
183 |
184 | @abstractmethod
185 | async def client_connected(self, connection: Connection):
186 | """
187 | Called when the connection is established with the client.
188 |
189 | Note:
190 | Note that the `mitm.core.Connection` object is not fully initialized yet,
191 | and only contains a valid client `mitm.core.Host`.
192 | """
193 | raise NotImplementedError
194 |
195 | @abstractmethod
196 | async def server_connected(self, connection: Connection):
197 | """
198 | Called when the connection is established with the server.
199 |
200 | Note:
201 | At this point the `mitm.core.Connection` object is fully initialized.
202 | """
203 | raise NotImplementedError
204 |
205 | @abstractmethod
206 | async def client_data(self, connection: Connection, data: bytes) -> bytes:
207 | """
208 | Called when data is received from the client.
209 |
210 | Note:
211 | Raw TLS/SSL handshake is not sent through this method. Everything should be
212 | decrypted beforehand.
213 |
214 | Args:
215 | request: The request received from the client.
216 |
217 | Returns:
218 | The request to send to the server.
219 | """
220 | raise NotImplementedError
221 |
222 | @abstractmethod
223 | async def server_data(self, connection: Connection, data: bytes) -> bytes:
224 | """
225 | Called when data is received from the server.
226 |
227 | Args:
228 | response: The response received from the server.
229 |
230 | Returns:
231 | The response to send back to the client.
232 | """
233 | raise NotImplementedError
234 |
235 | @abstractmethod
236 | async def client_disconnected(self, connection: Connection):
237 | """
238 | Called when the client disconnects.
239 | """
240 | raise NotImplementedError
241 |
242 | @abstractmethod
243 | async def server_disconnected(self, connection: Connection):
244 | """
245 | Called when the server disconnects.
246 | """
247 | raise NotImplementedError
248 |
249 | def __repr__(self) -> str: # pragma: no cover
250 | return f"Middleware({self.__class__.__name__})"
251 |
252 |
253 | class InvalidProtocol(Exception): # pragma: no cover
254 | """
255 | Exception raised when the protocol did not work.
256 |
257 | This is the only error that `mitm.MITM` will catch. Throwing this error will
258 | continue the search for a valid protocol.
259 | """
260 |
261 |
262 | class Protocol(ABC): # pragma: no cover
263 | """
264 | Custom protocol implementation.
265 |
266 | Protocols are implementations on how the data flows between the client and server.
267 | Application-layer protocols are implemented by subclassing this class. Built-in
268 | protocols can be found in the `mitm.extension` package.
269 |
270 | Parameters:
271 | bytes_needed: Minimum number of bytes needed to determine the protocol.
272 | buffer_size: The size of the buffer to use when reading data.
273 | timeout: The timeout to use when reading data.
274 | keep_alive: Whether or not to keep the connection alive.
275 | """
276 |
277 | bytes_needed: int
278 | buffer_size: int
279 | timeout: int
280 | keep_alive: bool
281 |
282 | def __init__(
283 | self,
284 | certificate_authority: Optional[CertificateAuthority] = None,
285 | middlewares: Optional[List[Middleware]] = None,
286 | ):
287 | """
288 | Initializes the protocol.
289 |
290 | Args:
291 | certificate_authority: The certificate authority to use for the connection.
292 | middlewares: The middlewares to use for the connection.
293 | """
294 | self.certificate_authority = certificate_authority if certificate_authority else CertificateAuthority()
295 | self.middlewares = middlewares if middlewares else []
296 |
297 | @abstractmethod
298 | async def resolve(self, connection: Connection, data: bytes) -> Tuple[str, int, bool]:
299 | """
300 | Resolves the destination of the connection.
301 |
302 | Args:
303 | connection: Connection object containing a client host.
304 | data: The initial incoming data from the client.
305 |
306 | Returns:
307 | A tuple containing the host, port, and bool that indicates if the connection
308 | is encrypted.
309 |
310 | Raises:
311 | InvalidProtocol: If the connection failed.
312 | """
313 | raise NotImplementedError
314 |
315 | @abstractmethod
316 | async def connect(self, connection: Connection, host: str, port: int, tls: bool, data: bytes):
317 | """
318 | Attempts to connect to destination server using the given data. Returns `True`
319 | if the connection was successful, raises `InvalidProtocol` if the connection
320 | failed.
321 |
322 | Args:
323 | connection: Connection object containing a client host.
324 | data: The initial incoming data from the client.
325 |
326 | Raises:
327 | InvalidProtocol: If the connection failed.
328 | """
329 | raise NotImplementedError
330 |
331 | @abstractmethod
332 | async def handle(self, connection: Connection):
333 | """
334 | Handles the connection between a client and a server.
335 |
336 | Args:
337 | connection: Client/server connection to relay.
338 | """
339 | raise NotImplementedError
340 |
341 | def __repr__(self) -> str: # pragma: no cover
342 | return f"Protocol({self.__class__.__name__})"
343 |
--------------------------------------------------------------------------------
/mitm/crypto.py:
--------------------------------------------------------------------------------
1 | """
2 | Cryptography functionalities.
3 | """
4 |
5 | from functools import lru_cache
6 | import random
7 | import ssl
8 | from pathlib import Path
9 | from typing import Optional, Tuple, Union
10 |
11 | import OpenSSL
12 | from toolbox.sockets.ip import is_ip
13 |
14 | from mitm import __data__
15 |
16 | LRU_MAX_SIZE = 1024
17 | """
18 | Max size of the LRU cache used by `CertificateAuthority.new_context()` method. Defaults
19 | to 1024.
20 |
21 | Due to limitations of the Python's SSL module we are unable to load certificates/keys
22 | from memory; on every request we must dump the generated cert/key to disk and pass the
23 | paths `ssl.SSLContext.load_cert_chain()` method. For a few requests this is not an
24 | issue, but for a large quantity of requests this is a significant performance hit.
25 |
26 | To mitigate this issue we cache the generated SSLContext using
27 | `lru_cache `_.
28 | `LRU_MAX_SIZE` defines the maximum number of cached `ssl.SSLContexts` that can be stored
29 | in memory at one time. This value can be modified by editing it _before_
30 | `CertificateAuthority` is used elsewhere.
31 |
32 | .. code-block:: python
33 |
34 | from mitm import MITM, CertificateAuthority, middleware, protocol, crypto
35 | from pathlib import Path
36 |
37 | # Updates the maximum size of the LRU cache.
38 | crypto.LRU_MAX_SIZE = 2048
39 |
40 | # Rest of the code goes here.
41 | """
42 |
43 |
44 | def new_RSA(bits: int = 2048) -> OpenSSL.crypto.PKey: # pylint: disable=invalid-name
45 | """
46 | Generates an RSA pair.
47 |
48 | This function is intended to be utilized with :py:func:`new_X509`. See function
49 | :py:func:`new_pair` to understand how to generate a valid RSA and X509 pair for
50 | SSL/TLS use.
51 |
52 | Args:
53 | bits: Size of the RSA key. Defaults to 2048.
54 | """
55 |
56 | rsa = OpenSSL.crypto.PKey()
57 | rsa.generate_key(OpenSSL.crypto.TYPE_RSA, bits)
58 | return rsa
59 |
60 |
61 | def new_X509( # pylint: disable=invalid-name
62 | country_name: str = "US",
63 | state_or_province_name: str = "New York",
64 | locality: str = "New York",
65 | organization_name: str = "mitm",
66 | organization_unit_name: str = "mitm",
67 | common_name: str = "mitm",
68 | serial_number: Optional[int] = None,
69 | time_not_before: int = 0, # 0 means now.
70 | time_not_after: int = 1 * (365 * 24 * 60 * 60), # 1 year.
71 | ) -> OpenSSL.crypto.X509:
72 | """
73 | Generates a non-signed X509 certificate.
74 |
75 | This function is intended to be utilized with :py:func:`new_RSA`. See function
76 | :py:func:`new_pair` to understand how to generate a valid RSA and X509 pair for
77 | SSL/TLS use.
78 |
79 | Args:
80 | country_name: Country name code. Defaults to ``US``.
81 | state_or_province_name: State or province name. Defaults to ``New York``.
82 | locality: Locality name. Can be any. Defaults to ``New York``.
83 | organization_name: Name of the org generating the cert. Defaults to ``mitm``.
84 | organization_unit_name: Name of the subunit of the org. Defaults to ``mitm``.
85 | common_name: Server name protected by the SSL cert. Defaults to hostname.
86 | serial_number: A unique serial number. Any number between 0 and 2^64-1. Defaults to random number.
87 | time_not_before: Time since cert is valid. 0 means now. Defaults to ``0``.
88 | time_not_after: Time when cert is no longer valid. Defaults to 5 years.
89 | """
90 |
91 | cert = OpenSSL.crypto.X509()
92 | cert.get_subject().C = country_name
93 | cert.get_subject().ST = state_or_province_name
94 | cert.get_subject().L = locality
95 | cert.get_subject().O = organization_name
96 | cert.get_subject().OU = organization_unit_name
97 | cert.get_subject().CN = common_name
98 | cert.set_serial_number(serial_number or random.randint(0, 2**64 - 1))
99 | cert.set_version(2)
100 | cert.gmtime_adj_notBefore(time_not_before)
101 | cert.gmtime_adj_notAfter(time_not_after)
102 | cert.set_issuer(cert.get_subject())
103 | return cert
104 |
105 |
106 | class CertificateAuthority:
107 | """
108 | Certificate Authority interface.
109 | """
110 |
111 | def __init__(
112 | self,
113 | key: Optional[OpenSSL.crypto.PKey] = None,
114 | cert: Optional[OpenSSL.crypto.X509] = None,
115 | ):
116 | """
117 | Generates a certificate authority.
118 |
119 | Args:
120 | key: Private key of the CA. Generated if not provided.
121 | cert: Unsigned certificate of the CA. Generated if not provided.
122 | """
123 | self.key = key if key else new_RSA()
124 | self.cert = cert if cert else new_X509()
125 |
126 | # Creates CA.
127 | self.cert.set_pubkey(self.key)
128 | self.cert.add_extensions(
129 | [
130 | OpenSSL.crypto.X509Extension(b"basicConstraints", True, b"CA:TRUE, pathlen:0"),
131 | OpenSSL.crypto.X509Extension(b"keyUsage", True, b"keyCertSign, cRLSign"),
132 | OpenSSL.crypto.X509Extension(b"subjectKeyIdentifier", False, b"hash", subject=self.cert),
133 | ],
134 | )
135 | self.cert.sign(self.key, "sha256")
136 |
137 | @classmethod
138 | def init(cls, path: Path):
139 | """
140 | Helper init method to initialize or load a CA.
141 |
142 | Args:
143 | path: The path where `mitm.pem` and `mitm.key` are to be loaded/saved.
144 | """
145 |
146 | pem, key = path / "mitm.pem", path / "mitm.key"
147 | if not pem.exists() or not key.exists():
148 | certificate_authority = CertificateAuthority()
149 | certificate_authority.save(cert_path=pem, key_path=key)
150 | else:
151 | certificate_authority = CertificateAuthority.load(cert_path=pem, key_path=key)
152 |
153 | return certificate_authority
154 |
155 | def new_X509(self, host: str) -> Tuple[OpenSSL.crypto.X509, OpenSSL.crypto.PKey]: # pylint: disable=invalid-name
156 | """
157 | Generates a new certificate for the host.
158 |
159 | Note:
160 | The hostname must be a valid IP address or a valid hostname.
161 |
162 | Args:
163 | host: Hostname to generate the certificate for.
164 |
165 | Returns:
166 | A tuple of the certificate and private key.
167 | """
168 |
169 | # Generate a new key pair.
170 | key = new_RSA()
171 |
172 | # Generates new X509Request.
173 | req = OpenSSL.crypto.X509Req()
174 | req.get_subject().CN = host.encode("utf-8")
175 | req.set_pubkey(key)
176 | req.sign(key, "sha256")
177 |
178 | # Generates new X509 certificate.
179 | cert = new_X509(common_name=host)
180 | cert.set_issuer(self.cert.get_subject())
181 | cert.set_pubkey(req.get_pubkey())
182 |
183 | # Sets the certificate 'subjectAltName' extension.
184 | hosts = [f"DNS:{host}"]
185 |
186 | if is_ip(host):
187 | hosts += [f"IP:{host}"]
188 | else:
189 | hosts += [f"DNS:*.{host}"]
190 |
191 | hosts = ", ".join(hosts).encode("utf-8")
192 | cert.add_extensions([OpenSSL.crypto.X509Extension(b"subjectAltName", False, hosts)])
193 |
194 | # Signs the certificate with the CA's key.
195 | cert.sign(self.key, "sha256")
196 |
197 | return cert, key
198 |
199 | @lru_cache(maxsize=LRU_MAX_SIZE)
200 | def new_context(self, host: str) -> ssl.SSLContext:
201 | """
202 | Generates a new SSLContext with the given X509 certificate and private key.
203 |
204 | Args:
205 | X509: X509 certificate.
206 | PKey: Private key.
207 |
208 | Returns:
209 | The SSLContext with the certificate loaded.
210 | """
211 |
212 | # Generates cert/key for the host.
213 | cert, key = self.new_X509(host)
214 |
215 | # Dump the cert and key.
216 | cert_dump = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
217 | key_dump = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)
218 |
219 | # Store cert and key into file. Unfortunately we need to store them in disk
220 | # because SSLContext does not support loading from memory. This is a limitation
221 | # of the Python standard library, and the community: https://bugs.python.org/issue16487
222 | # Alternatives cannot be used for this because this context is eventually used
223 | # by asyncio.get_event_loop().start_tls(..., sslcontext=..., ...) parameter,
224 | # which only support ssl.SSLContext. To mitigate this we use lru_cache to
225 | # cache the SSLContext for each host. It works fairly well, but its not the
226 | # preferred way to do it... loading from memory would be better.
227 | cert_path, key_path = __data__ / "temp.crt", __data__ / "temp.key"
228 | cert_path.parent.mkdir(parents=True, exist_ok=True)
229 | with cert_path.open("wb") as file:
230 | file.write(cert_dump)
231 | key_path.parent.mkdir(parents=True, exist_ok=True)
232 | with key_path.open("wb") as file:
233 | file.write(key_dump)
234 |
235 | # Creates new SSLContext.
236 | context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
237 | context.load_cert_chain(certfile=cert_path, keyfile=key_path)
238 |
239 | # Remove the temporary files.
240 | cert_path.unlink()
241 | key_path.unlink()
242 |
243 | return context
244 |
245 | def save(self, cert_path: Union[Path, str], key_path: Union[Path, str]):
246 | """
247 | Saves the certificate authority and its private key to disk.
248 |
249 | Args:
250 | cert_path: Path to the certificate file.
251 | key_path: Path to the key file.
252 | """
253 | cert_path, key_path = Path(cert_path), Path(key_path)
254 |
255 | cert_path.parent.mkdir(parents=True, exist_ok=True)
256 | with cert_path.open("wb") as file:
257 | file.write(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, self.cert))
258 |
259 | key_path.parent.mkdir(parents=True, exist_ok=True)
260 | with key_path.open("wb") as file:
261 | file.write(OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, self.key))
262 |
263 | @classmethod
264 | def load(cls, cert_path: Union[Path, str], key_path: Union[Path, str]) -> "CertificateAuthority":
265 | """
266 | Loads the certificate authority and its private key from disk.
267 |
268 | Args:
269 | cert_path: Path to the certificate file.
270 | key_path: Path to the key file.
271 | """
272 | cert_path, key_path = Path(cert_path), Path(key_path)
273 |
274 | with cert_path.open("rb") as file:
275 | cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, file.read())
276 |
277 | with key_path.open("rb") as file:
278 | key = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, file.read())
279 |
280 | return cls(key, cert)
281 |
--------------------------------------------------------------------------------
/mitm/extension/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Built-in extensions for mitm.
3 | """
4 | from mitm.extension import middleware
5 | from mitm.extension import protocol
6 | from mitm.extension.middleware import *
7 | from mitm.extension.protocol import *
8 |
--------------------------------------------------------------------------------
/mitm/extension/middleware.py:
--------------------------------------------------------------------------------
1 | """
2 | Custom middlware implementation for the MITM proxy.
3 | """
4 |
5 | import logging
6 |
7 | import httpq
8 | from toolbox.string.color import bold
9 | from mitm.core import Connection, Middleware
10 |
11 | logger = logging.getLogger(__package__)
12 |
13 |
14 | class Log(Middleware):
15 | """
16 | Middleware that logs all events to the console.
17 | """
18 |
19 | def __init__(self):
20 | self.connection: Connection = None
21 |
22 | async def mitm_started(self, host: str, port: int):
23 | logger.info(f"MITM server started on {bold(f'{host}:{port}')}.")
24 |
25 | async def client_connected(self, connection: Connection):
26 | logger.info(f"Client {bold(connection.client)} has connected.")
27 |
28 | async def server_connected(self, connection: Connection):
29 | logger.info(f"Client {bold(connection.client)} has connected to server {bold(connection.server)}.")
30 |
31 | async def client_data(self, connection: Connection, data: bytes) -> bytes:
32 |
33 | # The first request is intended for the 'mitm' server to discover the
34 | # destination server.
35 | if not connection.server:
36 | logger.info(f"Client {connection.client} to mitm: \n\n\t{data}\n")
37 |
38 | # All requests thereafter are intended for the destination server.
39 | else: # pragma: no cover
40 | logger.info(f"Client {connection.client} to {connection.server}: \n\n\t{data}\n")
41 |
42 | return data
43 |
44 | async def server_data(self, connection: Connection, data: bytes) -> bytes:
45 | logger.info(f"Server {connection.server} to client {connection.client}: \n\n\t{data}\n")
46 | return data
47 |
48 | async def client_disconnected(self, connection: Connection):
49 | logger.info(f"Client {connection.client} has disconnected.")
50 |
51 | async def server_disconnected(self, connection: Connection):
52 | logger.info(f"Server {connection.server} has disconnected.")
53 |
54 |
55 | class HTTPLog(Log): # pragma: no cover
56 | """
57 | Middlewares that logs all HTTP events to the console with pretty-print.
58 |
59 | Notes:
60 | Do not use this middleware if there is a chance that the request or response
61 | will not be HTTP. This should only be used if you have control of all the
62 | requests coming into the proxy. If you are setting your computer's proxy
63 | settings to `mitm` you should not use this middleware as things will not work.
64 | """
65 |
66 | def __init__(self): # pylint: disable=super-init-not-called
67 | self.connection: Connection = None
68 |
69 | async def client_data(self, connection: Connection, data: bytes) -> bytes:
70 |
71 | req = httpq.Request.parse(data)
72 |
73 | # The first request is intended for the 'mitm' server to discover the
74 | # destination server.
75 | if not connection.server:
76 | logger.info(f"Client {connection.client} to mitm: \n\n{req}\n")
77 |
78 | # All requests thereafter are intended for the destination server.
79 | else:
80 | logger.info(f"Client {connection.client} to {connection.server}: \n\n{req}\n")
81 |
82 | return data
83 |
84 | async def server_data(self, connection: Connection, data: bytes) -> bytes:
85 | resp = httpq.Response.parse(data)
86 | logger.info(f"Server {connection.server} to client {connection.client}: \n\n{resp}\n")
87 | return data
88 |
89 | async def client_disconnected(self, connection: Connection):
90 | logger.info(f"Client {connection.client} has disconnected.")
91 |
92 | async def server_disconnected(self, connection: Connection):
93 | logger.info(f"Server {connection.server} has disconnected.")
94 |
--------------------------------------------------------------------------------
/mitm/extension/protocol.py:
--------------------------------------------------------------------------------
1 | """
2 | Custom protocol implementations for the MITM proxy.
3 | """
4 | import asyncio
5 | import ssl
6 | from typing import Tuple
7 |
8 | from httpq import Request
9 | from toolbox.asyncio.streams import tls_handshake
10 |
11 | from mitm.core import Connection, Flow, Host, InvalidProtocol, Protocol
12 |
13 |
14 | class HTTP(Protocol):
15 | """
16 | Adds support for HTTP protocol (with TLS support).
17 |
18 | This protocol adds HTTP and HTTPS proxy support to the `mitm`. Note that by
19 | "HTTPS proxy" we mean a proxy that supports the `CONNECT` statement, and not
20 | one that instantly performs a TLS handshake on connection with the client (though
21 | this can be added if needed).
22 |
23 | `bytes_needed` is set to 8192 to ensure we can read the first line of the request.
24 | The HTTP/1.1 protocol does not define a minimum length for the first line, so we
25 | use the largest number found in other projects.
26 | """
27 |
28 | bytes_needed: int = 8192
29 | buffer_size: int = 8192
30 | timeout: int = 15
31 | keep_alive: bool = True
32 |
33 | async def resolve(self, connection: Connection, data: bytes) -> Tuple[str, int, bool]:
34 | """
35 | Resolves the destination server for the protocol.
36 |
37 | Args:
38 | connection: Connection object containing a client host.
39 | data: The initial incoming data from the client.
40 |
41 | Returns:
42 | A tuple containing the host, port, and bool if the connection is encrypted.
43 |
44 | Raises:
45 | InvalidProtocol: If the connection failed.
46 | """
47 | try:
48 | request = Request.parse(data)
49 | except: # pragma: no cover
50 | raise InvalidProtocol # pylint: disable=raise-missing-from
51 |
52 | # Deal with 'CONNECT'.
53 | tls = False
54 | if request.method == "CONNECT":
55 | tls = True
56 |
57 | # Get the hostname and port.
58 | if not request.target:
59 | raise InvalidProtocol
60 | host, port = request.target.string.split(":")
61 |
62 | # Deal with any other HTTP method.
63 | elif request.method:
64 |
65 | # Get the hostname and port.
66 | if "Host" not in request.headers:
67 | raise InvalidProtocol
68 | host, port = request.headers.get("Host").string, 80
69 |
70 | # Unable to parse the request.
71 | elif not request.method:
72 | raise InvalidProtocol
73 |
74 | return host, int(port), tls
75 |
76 | async def connect(self, connection: Connection, host: str, port: int, tls: bool, data: bytes):
77 | """
78 | Connects to the destination server if the data is a valid HTTP request.
79 |
80 | Args:
81 | connection: The connection to the destination server.
82 | host: The hostname of the destination server.
83 | port: The port of the destination server.
84 | tls: Whether the connection is encrypted.
85 | data: The initial data received from the client.
86 |
87 | Raises:
88 | InvalidProtocol: If the connection failed.
89 | """
90 |
91 | # Generate certificate if TLS.
92 | if tls:
93 |
94 | # Accept client connection.
95 | connection.client.writer.write(b"HTTP/1.1 200 OK\r\n\r\n")
96 | await connection.client.writer.drain()
97 |
98 | # Generates new context specific to the host.
99 | ssl_context = self.certificate_authority.new_context(host)
100 |
101 | # Perform handshake.
102 | try:
103 | await tls_handshake(
104 | reader=connection.client.reader,
105 | writer=connection.client.writer,
106 | ssl_context=ssl_context,
107 | server_side=True,
108 | )
109 | except ssl.SSLError as err:
110 | raise InvalidProtocol from err
111 |
112 | # Connect to the destination server and send the initial request.
113 | reader, writer = await asyncio.open_connection(
114 | host=host,
115 | port=port,
116 | ssl=tls,
117 | )
118 | connection.server = Host(reader, writer)
119 |
120 | # Send initial request if not SSL/TLS connection.
121 | if not tls:
122 | connection.server.writer.write(data)
123 | await connection.server.writer.drain()
124 |
125 | async def handle(self, connection: Connection):
126 | """
127 | Handles the connection between a client and a server.
128 |
129 | Args:
130 | connection: Client/server connection to relay.
131 | """
132 |
133 | # Keeps the connection alive until the client or server closes it.
134 | run_once = True
135 | while (
136 | not connection.client.reader.at_eof()
137 | and not connection.server.reader.at_eof()
138 | and (self.keep_alive or run_once)
139 | ):
140 |
141 | # Keeps trying to relay data until the connection closes.
142 | event = asyncio.Event()
143 | await asyncio.gather(
144 | self.relay(connection, event, Flow.SERVER_TO_CLIENT),
145 | self.relay(connection, event, Flow.CLIENT_TO_SERVER),
146 | )
147 |
148 | # Run the while loop only one iteration if keep_alive is False.
149 | run_once = False
150 |
151 | async def relay(self, connection: Connection, event: asyncio.Event, flow: Flow):
152 | """
153 | Relays HTTP data between the client and the server.
154 |
155 | Args:
156 | connection: Client/server connection to relay.
157 | event: Event to wait on.
158 | flow: The flow to relay.
159 | """
160 |
161 | if flow == Flow.CLIENT_TO_SERVER:
162 | reader = connection.client.reader
163 | writer = connection.server.writer
164 | elif flow == Flow.SERVER_TO_CLIENT:
165 | reader = connection.server.reader
166 | writer = connection.client.writer
167 |
168 | while not event.is_set() and not reader.at_eof():
169 | data = None
170 | try:
171 | data = await asyncio.wait_for(
172 | reader.read(self.buffer_size),
173 | timeout=self.timeout,
174 | )
175 | except asyncio.exceptions.TimeoutError:
176 | pass
177 |
178 | if not data:
179 | event.set()
180 | break
181 |
182 | # Pass data through middlewares.
183 | for middleware in self.middlewares:
184 | if flow == Flow.SERVER_TO_CLIENT:
185 | data = await middleware.server_data(connection, data)
186 | elif flow == Flow.CLIENT_TO_SERVER:
187 | data = await middleware.client_data(connection, data)
188 |
189 | writer.write(data)
190 | await writer.drain()
191 |
--------------------------------------------------------------------------------
/mitm/mitm.py:
--------------------------------------------------------------------------------
1 | """
2 | Man-in-the-middle.
3 | """
4 |
5 | import asyncio
6 | import logging
7 | from typing import List, Optional
8 |
9 | from toolbox.asyncio.pattern import CoroutineClass
10 |
11 | from mitm import __data__
12 | from mitm.core import Connection, Host, Middleware, Protocol
13 | from mitm.crypto import CertificateAuthority
14 | from mitm.extension.middleware import Log
15 | from mitm.extension.protocol import HTTP, InvalidProtocol
16 |
17 | logger = logging.getLogger(__package__)
18 | logging.getLogger("asyncio").setLevel(logging.CRITICAL)
19 |
20 |
21 | class MITM(CoroutineClass):
22 | """
23 | Man-in-the-middle server.
24 | """
25 |
26 | def __init__(
27 | self,
28 | host: str = "127.0.0.1",
29 | port: int = 8888,
30 | protocols: Optional[List[Protocol]] = None,
31 | middlewares: Optional[List[Middleware]] = None,
32 | certificate_authority: Optional[CertificateAuthority] = None,
33 | run: bool = False,
34 | ):
35 | """
36 | Initializes the MITM class.
37 |
38 | Args:
39 | host: Host to listen on. Defaults to `127.0.0.1`.
40 | port: Port to listen on. Defaults to `8888`.
41 | protocols: List of protocols to use. Defaults to `[protocol.HTTP]`.
42 | middlewares: List of middlewares to use. Defaults to `[middleware.Log]`.
43 | certificate_authority: Certificate authority to use. Defaults to `CertificateAuthority()`.
44 | run: Whether to start the server immediately. Defaults to `False`.
45 |
46 | Example:
47 |
48 | .. code-block:: python
49 |
50 | from mitm import MITM
51 |
52 | mitm = MITM()
53 | mitm.run()
54 | """
55 | self.host = host
56 | self.port = port
57 | self.certificate_authority = certificate_authority if certificate_authority else CertificateAuthority()
58 |
59 | # Stores the CA certificate and private key.
60 | cert_path, key_path = __data__ / "mitm.crt", __data__ / "mitm.key"
61 | self.certificate_authority.save(cert_path=cert_path, key_path=key_path)
62 |
63 | # Initialize any middleware that is not already initialized.
64 | middlewares = middlewares if middlewares else [Log]
65 | new_middlewares = []
66 | for middleware in middlewares:
67 | if isinstance(middleware, type):
68 | middleware = middleware()
69 | new_middlewares.append(middleware)
70 | self.middlewares = new_middlewares
71 |
72 | # Initialize any protocol that is not already initialized.
73 | protocols = protocols if protocols else [HTTP]
74 | new_protocols = []
75 | for protocol in protocols:
76 | if isinstance(protocol, type):
77 | protocol = protocol(certificate_authority=self.certificate_authority, middlewares=self.middlewares)
78 | new_protocols.append(protocol)
79 | self.protocols = new_protocols
80 |
81 | super().__init__(run=run)
82 |
83 | async def entry(self): # pragma: no cover
84 | """
85 | Entry point for the MITM class.
86 | """
87 | try:
88 | server = await asyncio.start_server(
89 | lambda reader, writer: self.mitm(
90 | Connection(
91 | client=Host(reader=reader, writer=writer),
92 | server=Host(),
93 | )
94 | ),
95 | host=self.host,
96 | port=self.port,
97 | )
98 | except OSError as err:
99 | self._loop.stop()
100 | raise err
101 |
102 | for middleware in self.middlewares:
103 | await middleware.mitm_started(host=self.host, port=self.port)
104 |
105 | async with server:
106 | await server.serve_forever()
107 |
108 | async def mitm(self, connection: Connection):
109 | """
110 | Handles an incoming connection (single connection).
111 |
112 | Warning:
113 | This method is not intended to be called directly.
114 | """
115 |
116 | # Calls middlewares for client initial connect.
117 | for middleware in self.middlewares:
118 | await middleware.client_connected(connection=connection)
119 |
120 | # Gets the bytes needed to identify the protocol.
121 | min_bytes_needed = max(proto.bytes_needed for proto in self.protocols)
122 | data = await connection.client.reader.read(n=min_bytes_needed)
123 |
124 | # Calls middleware on client's data.
125 | for middleware in self.middlewares:
126 | data = await middleware.client_data(connection=connection, data=data)
127 |
128 | # Finds the protocol that matches the data.
129 | proto = None
130 | for prtcl in self.protocols:
131 | proto = prtcl
132 | try:
133 | # Attempts to resolve the protocol, and connect to the server.
134 | host, port, tls = await proto.resolve(connection=connection, data=data)
135 | await proto.connect(connection=connection, host=host, port=port, tls=tls, data=data)
136 | except InvalidProtocol: # pragma: no cover
137 | proto = None
138 | else:
139 | # Stop searching for working protocols.
140 | break
141 |
142 | # Protocol was found, and we connected to a server.
143 | if proto and connection.server:
144 |
145 | # Sets the connection protocol.
146 | connection.protocol = proto
147 |
148 | # Calls middleware for server initial connect.
149 | for middleware in self.middlewares:
150 | await middleware.server_connected(connection=connection)
151 |
152 | # Handles the data between the client and server.
153 | await proto.handle(connection=connection)
154 |
155 | # Protocol identified, but we did not connect to a server.
156 | elif proto and not connection.server: # pragma: no cover
157 | raise ValueError(
158 | "The protocol was found, but the server was not connected to succesfully. "
159 | f"Check the {proto.__class__.__name__} implementation."
160 | )
161 |
162 | # No protocol was found for the data.
163 | else: # pragma: no cover
164 | raise ValueError("No protocol was found. Check the protocols list.")
165 |
166 | # If a server connection exists after handling it, we close it.
167 | if connection.server and connection.server.mitm_managed:
168 | connection.server.writer.close()
169 | await connection.server.writer.wait_closed()
170 |
171 | # Calls the server's 'disconnected' middleware.
172 | for middleware in self.middlewares:
173 | await middleware.server_disconnected(connection=connection)
174 |
175 | # Attempts to disconnect with the client.
176 | # In some instances 'wait_closed()' might hang. This is a known issue that
177 | # happens when and if the client keeps the connection alive, and, unfortunately,
178 | # there is nothing we can do about it. This is a reported bug in asyncio.
179 | # https://bugs.python.org/issue39758
180 | if connection.client and connection.client.mitm_managed:
181 | connection.client.writer.close()
182 | await connection.client.writer.wait_closed()
183 |
184 | # Calls the client 'disconnected' middleware.
185 | for middleware in self.middlewares:
186 | await middleware.client_disconnected(connection=connection)
187 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | line-length = 120
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pbr==5.9.0
2 | PyOpenSSL==23.1.1
3 | appdirs==1.4.4
4 | httpq==1.2.0
5 | toolbox==1.11.0
6 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = mitm
3 | author = Felipe Faria
4 | summary = Man-in-the-middle proxy with customizable options.
5 | description-file = README.md
6 | description-content-type = text/markdown; charset=UTF-8
7 | requires-python = >=3.5
8 | home-page = https://github.com/synchronizing/mitm
9 | project_urls =
10 | Bug Tracker = https://github.com/synchronizing/mitm/issues
11 | Documentation = https://synchronizing.github.io/mitm/
12 | Source Code = https://github.com/synchronizing/mitm/tree/master
13 | license = MIT
14 | classifier =
15 | Programming Language :: Python :: Implementation :: CPython
16 | Development Status :: 5 - Production/Stable
17 | License :: OSI Approved :: MIT License
18 | Natural Language :: English
19 | Topic :: Software Development :: Libraries
20 | Topic :: Utilities
21 | keywords =
22 | mitm
23 |
24 | [extras]
25 | dev =
26 | pytest
27 | pytest-cov
28 | pytest-asyncio
29 | coveralls
30 | sphinx
31 | sphinx-copybutton
32 | sphinx-autobuild
33 | furo
34 |
35 | [files]
36 | packages =
37 | mitm
38 |
39 | [flake8]
40 | max-line-length = 88
41 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup(
4 | setup_requires=["pbr"],
5 | pbr=True,
6 | )
7 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/synchronizing/mitm/a189234b382518d76658b6e489a4a170898575e1/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import mitm
2 | import pytest
3 | import asyncio
4 |
5 | HOST = "127.0.0.1"
6 | PORT = 8888
7 | BUFFER_SIZE = 1024
8 |
9 |
10 | async def start():
11 | """
12 | Starts the MITM server.
13 | """
14 | loop = asyncio.get_event_loop()
15 | mitm_ = mitm.MITM()
16 | try:
17 | srv = await asyncio.start_server(
18 | lambda reader, writer: mitm_.mitm(
19 | mitm.Connection(
20 | client=mitm.Host(reader=reader, writer=writer),
21 | server=mitm.Host(),
22 | )
23 | ),
24 | host=HOST,
25 | port=PORT,
26 | )
27 | except OSError as e:
28 | loop.stop()
29 | raise e
30 |
31 | async with srv:
32 | await srv.serve_forever()
33 |
34 |
35 | @pytest.fixture(scope="session")
36 | def event_loop():
37 | return asyncio.get_event_loop()
38 |
39 |
40 | @pytest.fixture(autouse=True, scope="session")
41 | def server(event_loop):
42 | task = asyncio.ensure_future(start(), loop=event_loop)
43 | # Sleeps to allow the server to start.
44 | event_loop.run_until_complete(asyncio.sleep(1))
45 |
46 | try:
47 | yield
48 | finally:
49 | task.cancel()
50 |
--------------------------------------------------------------------------------
/tests/extension/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/synchronizing/mitm/a189234b382518d76658b6e489a4a170898575e1/tests/extension/__init__.py
--------------------------------------------------------------------------------
/tests/extension/test_middleware.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from mitm import extension, core
3 |
4 |
5 | class Test_Log:
6 | log = extension.Log()
7 | connection = core.Connection(core.Host(), core.Host())
8 |
9 | @pytest.mark.asyncio
10 | async def test_init(self):
11 | log = extension.Log()
12 | assert repr(log) == "Middleware(Log)"
13 |
14 | @pytest.mark.asyncio
15 | async def test_mitm_started(self):
16 | await self.log.mitm_started("localhost", 80)
17 |
18 | @pytest.mark.asyncio
19 | async def test_client_connected(self):
20 | await self.log.client_connected(self.connection)
21 |
22 | @pytest.mark.asyncio
23 | async def test_server_connected(self):
24 | await self.log.server_connected(self.connection)
25 |
26 | @pytest.mark.asyncio
27 | async def test_client_data(self):
28 | data = b"hello"
29 | ret = await self.log.client_data(self.connection, data)
30 | assert ret == data
31 |
32 | @pytest.mark.asyncio
33 | async def test_server_data(self):
34 | data = b"hello"
35 | ret = await self.log.server_data(self.connection, data)
36 | assert ret == data
37 |
38 | @pytest.mark.asyncio
39 | async def test_client_disconnected(self):
40 | await self.log.client_disconnected(self.connection)
41 |
42 | @pytest.mark.asyncio
43 | async def test_server_disconnected(self):
44 | await self.log.server_disconnected(self.connection)
45 |
--------------------------------------------------------------------------------
/tests/extension/test_protocol.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import pytest
4 | from mitm import HTTP, Connection, Host, InvalidProtocol
5 |
6 |
7 | class Test_HTTP:
8 | HTTP = HTTP()
9 |
10 | def test_init(self):
11 | assert self.HTTP.bytes_needed
12 | assert self.HTTP.buffer_size
13 | assert self.HTTP.timeout
14 | assert self.HTTP.keep_alive
15 |
16 | @pytest.mark.asyncio
17 | async def test_resolve(self):
18 | connection = Connection(Host(), Host())
19 | data = b"GET / HTTP/1.1\r\nHost: google.com\r\n\r\n"
20 | host, port, tls = await self.HTTP.resolve(connection, data)
21 | assert host == "google.com"
22 | assert port == 80
23 | assert not tls
24 |
25 | with pytest.raises(InvalidProtocol):
26 | data = b"junk data"
27 | await self.HTTP.resolve(connection, data)
28 |
29 | @pytest.mark.asyncio
30 | async def test_connect_no_tls(self):
31 | connection = Connection(Host(), Host())
32 | data = b"GET / HTTP/1.1\r\nHost: google.com\r\n\r\n"
33 | host, port, tls = await self.HTTP.resolve(connection, data)
34 |
35 | # Connects to the host, port.
36 | await self.HTTP.connect(connection, host, port, tls, data)
37 |
38 | # Checks if we connected to the host, port.
39 | assert connection.server.reader
40 | assert connection.server.writer
41 |
--------------------------------------------------------------------------------
/tests/requirements.txt:
--------------------------------------------------------------------------------
1 | pytest
2 | pytest-asyncio
3 | pytest-cov
4 | coveralls
5 |
--------------------------------------------------------------------------------
/tests/test_core.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from mitm import Host, Connection
3 | import asyncio
4 |
5 |
6 | @pytest.mark.asyncio
7 | async def test_Host():
8 |
9 | host = Host()
10 | assert bool(host) is False
11 | assert str(host) == ""
12 |
13 | # 93.184.216.34 = Example.com
14 | reader, writer = await asyncio.open_connection("93.184.216.34", 80)
15 | host = Host(reader=reader, writer=writer, mitm_managed=False)
16 | assert host.host == "93.184.216.34"
17 | assert host.port == 80
18 | assert bool(host)
19 | assert str(host) == "93.184.216.34:80"
20 |
21 |
22 | @pytest.mark.asyncio
23 | async def test_Connection():
24 | host1 = Host()
25 | host2 = Host()
26 | connection = Connection(client=host1, server=host2)
27 | assert repr(connection) == "Connection(client=, server=, protocol=None)"
28 |
--------------------------------------------------------------------------------
/tests/test_crypto.py:
--------------------------------------------------------------------------------
1 | import ssl
2 | import tempfile
3 | from pathlib import Path
4 |
5 | import OpenSSL
6 | from mitm import crypto
7 |
8 |
9 | def test_new_RSA():
10 | key = crypto.new_RSA()
11 | assert isinstance(key, OpenSSL.crypto.PKey)
12 |
13 |
14 | def test_new_X509():
15 | cert = crypto.new_X509()
16 | assert isinstance(cert, OpenSSL.crypto.X509)
17 |
18 |
19 | class Test_CertificateAuthority:
20 | def test_init(self):
21 | ca = crypto.CertificateAuthority()
22 | assert isinstance(ca, crypto.CertificateAuthority)
23 | assert isinstance(ca.key, OpenSSL.crypto.PKey)
24 | assert isinstance(ca.cert, OpenSSL.crypto.X509)
25 |
26 | def test_helper_init(self):
27 | with tempfile.TemporaryDirectory() as directory:
28 | path = Path(directory)
29 |
30 | # No cert exists in path. Creates and saves it.
31 | ca = crypto.CertificateAuthority.init(path)
32 | assert isinstance(ca, crypto.CertificateAuthority)
33 | assert isinstance(ca.key, OpenSSL.crypto.PKey)
34 | assert isinstance(ca.cert, OpenSSL.crypto.X509)
35 |
36 | # Assert the files were created.
37 | pem = path / "mitm.pem"
38 | key = path / "mitm.key"
39 |
40 | assert pem.exists()
41 | assert key.exists()
42 |
43 | # Loads the cert from the path.
44 | ca = crypto.CertificateAuthority.init(path)
45 | assert isinstance(ca, crypto.CertificateAuthority)
46 | assert isinstance(ca.key, OpenSSL.crypto.PKey)
47 | assert isinstance(ca.cert, OpenSSL.crypto.X509)
48 |
49 | def test_new_X509(self):
50 |
51 | with tempfile.TemporaryDirectory() as directory:
52 | path = Path(directory)
53 | ca = crypto.CertificateAuthority.init(path)
54 |
55 | # Creates signed cert.
56 | cert, key = ca.new_X509("example.com")
57 |
58 | assert isinstance(cert, OpenSSL.crypto.X509)
59 | assert isinstance(cert.get_pubkey(), OpenSSL.crypto.PKey)
60 | assert isinstance(key, OpenSSL.crypto.PKey)
61 |
62 | # Creates signed cert.
63 | cert, key = ca.new_X509("127.0.0.1")
64 |
65 | assert isinstance(cert, OpenSSL.crypto.X509)
66 | assert isinstance(cert.get_pubkey(), OpenSSL.crypto.PKey)
67 | assert isinstance(key, OpenSSL.crypto.PKey)
68 |
69 | def test_new_context(self):
70 | with tempfile.TemporaryDirectory() as directory:
71 | path = Path(directory)
72 | ca = crypto.CertificateAuthority.init(path)
73 |
74 | # Creates a new SSL context.
75 | context = ca.new_context("example.com")
76 | assert isinstance(context, ssl.SSLContext)
77 |
--------------------------------------------------------------------------------
/tests/test_mitm.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import pytest
3 |
4 |
5 | from .conftest import HOST, PORT, BUFFER_SIZE
6 |
7 |
8 | @pytest.mark.asyncio
9 | async def test_connection():
10 | reader, writer = await asyncio.open_connection(HOST, PORT)
11 | assert reader
12 | assert writer
13 | writer.close()
14 |
15 |
16 | @pytest.mark.asyncio
17 | async def test_http_request():
18 | reader, writer = await asyncio.open_connection(HOST, PORT)
19 | writer.write(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
20 | data = await reader.read(BUFFER_SIZE)
21 | assert data.startswith(b"HTTP/1.1 200 OK\r\n")
22 | writer.close()
23 | await writer.wait_closed()
24 |
25 |
26 | @pytest.mark.asyncio
27 | async def test_https_request():
28 | reader, writer = await asyncio.open_connection(HOST, PORT)
29 | writer.write(b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n")
30 | data = await reader.read(BUFFER_SIZE)
31 | assert data.startswith(b"HTTP/1.1 200 OK\r\n")
32 | writer.close()
33 | await writer.wait_closed()
34 |
--------------------------------------------------------------------------------