django-http-inspector 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_http_inspector-0.1.4/LICENSE +21 -0
- django_http_inspector-0.1.4/PKG-INFO +129 -0
- django_http_inspector-0.1.4/README.md +101 -0
- django_http_inspector-0.1.4/pyproject.toml +42 -0
- django_http_inspector-0.1.4/setup.cfg +4 -0
- django_http_inspector-0.1.4/src/django_http_inspector/__init__.py +6 -0
- django_http_inspector-0.1.4/src/django_http_inspector/capture/__init__.py +0 -0
- django_http_inspector-0.1.4/src/django_http_inspector/capture/exchange.py +84 -0
- django_http_inspector-0.1.4/src/django_http_inspector/capture/headers.py +33 -0
- django_http_inspector-0.1.4/src/django_http_inspector/capture/url.py +51 -0
- django_http_inspector-0.1.4/src/django_http_inspector/config.py +102 -0
- django_http_inspector-0.1.4/src/django_http_inspector/inspector/__init__.py +0 -0
- django_http_inspector-0.1.4/src/django_http_inspector/inspector/app.py +292 -0
- django_http_inspector-0.1.4/src/django_http_inspector/inspector/presentation.py +195 -0
- django_http_inspector-0.1.4/src/django_http_inspector/inspector/security.py +45 -0
- django_http_inspector-0.1.4/src/django_http_inspector/inspector/templates.py +21 -0
- django_http_inspector-0.1.4/src/django_http_inspector/replay/__init__.py +0 -0
- django_http_inspector-0.1.4/src/django_http_inspector/replay/edit.py +119 -0
- django_http_inspector-0.1.4/src/django_http_inspector/replay/service.py +69 -0
- django_http_inspector-0.1.4/src/django_http_inspector/replay/target.py +68 -0
- django_http_inspector-0.1.4/src/django_http_inspector/replay/transport.py +85 -0
- django_http_inspector-0.1.4/src/django_http_inspector/static/django_http_inspector/inspect.css +139 -0
- django_http_inspector-0.1.4/src/django_http_inspector/static/django_http_inspector/inspect.js +210 -0
- django_http_inspector-0.1.4/src/django_http_inspector/storage/__init__.py +3 -0
- django_http_inspector-0.1.4/src/django_http_inspector/storage/records.py +84 -0
- django_http_inspector-0.1.4/src/django_http_inspector/storage/repository.py +276 -0
- django_http_inspector-0.1.4/src/django_http_inspector/storage/schema.py +40 -0
- django_http_inspector-0.1.4/src/django_http_inspector/templates/django_http_inspector/base.html +13 -0
- django_http_inspector-0.1.4/src/django_http_inspector/templates/django_http_inspector/index.html +101 -0
- django_http_inspector-0.1.4/src/django_http_inspector/wrapper/__init__.py +0 -0
- django_http_inspector-0.1.4/src/django_http_inspector/wrapper/input.py +68 -0
- django_http_inspector-0.1.4/src/django_http_inspector/wrapper/response.py +38 -0
- django_http_inspector-0.1.4/src/django_http_inspector/wrapper/wsgi.py +78 -0
- django_http_inspector-0.1.4/src/django_http_inspector.egg-info/PKG-INFO +129 -0
- django_http_inspector-0.1.4/src/django_http_inspector.egg-info/SOURCES.txt +50 -0
- django_http_inspector-0.1.4/src/django_http_inspector.egg-info/dependency_links.txt +1 -0
- django_http_inspector-0.1.4/src/django_http_inspector.egg-info/requires.txt +5 -0
- django_http_inspector-0.1.4/src/django_http_inspector.egg-info/top_level.txt +1 -0
- django_http_inspector-0.1.4/tests/test_config.py +75 -0
- django_http_inspector-0.1.4/tests/test_end_to_end.py +100 -0
- django_http_inspector-0.1.4/tests/test_input_capture.py +47 -0
- django_http_inspector-0.1.4/tests/test_inspector_security.py +23 -0
- django_http_inspector-0.1.4/tests/test_inspector_ui.py +187 -0
- django_http_inspector-0.1.4/tests/test_models.py +95 -0
- django_http_inspector-0.1.4/tests/test_presentation.py +100 -0
- django_http_inspector-0.1.4/tests/test_replay_edit.py +72 -0
- django_http_inspector-0.1.4/tests/test_replay_service.py +99 -0
- django_http_inspector-0.1.4/tests/test_replay_target.py +18 -0
- django_http_inspector-0.1.4/tests/test_replay_transport.py +45 -0
- django_http_inspector-0.1.4/tests/test_response_capture.py +37 -0
- django_http_inspector-0.1.4/tests/test_url_capture.py +22 -0
- django_http_inspector-0.1.4/tests/test_wrapper.py +111 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 django-http-inspector contributors
|
|
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.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-http-inspector
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: An embedded HTTP traffic inspector and replay tool for Django development
|
|
5
|
+
Author: django-http-inspector contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: django,http,webhook,inspector,replay,debugging
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: Django :: 4.2
|
|
12
|
+
Classifier: Framework :: Django :: 5.0
|
|
13
|
+
Classifier: Framework :: Django :: 5.1
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: Django>=4.2
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# django-http-inspector
|
|
30
|
+
|
|
31
|
+
django-http-inspector is an embedded HTTP traffic inspector for Django development. It captures requests and responses outside the project's middleware chain and can replay a captured request to the URL seen at capture time over a real HTTP connection.
|
|
32
|
+
|
|
33
|
+
It is a development tool, not a reverse proxy, tunnel, production observability platform, or production security boundary.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m pip install django-http-inspector
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Intended integration
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
# wsgi.py
|
|
45
|
+
from django.core.wsgi import get_wsgi_application
|
|
46
|
+
from django_http_inspector import InspectorWSGI
|
|
47
|
+
|
|
48
|
+
application = InspectorWSGI(get_wsgi_application())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python manage.py runserver
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Open `http://127.0.0.1:8000/__inspect/`.
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
DJANGO_HTTP_INSPECTOR = {
|
|
61
|
+
"ENABLED": DEBUG,
|
|
62
|
+
# Set True only on a trusted development network. No authentication is added.
|
|
63
|
+
"ALLOW_REMOTE": False,
|
|
64
|
+
"PATH": "/__inspect/",
|
|
65
|
+
"CAPTURE_MAX_BYTES": 1024 * 1024,
|
|
66
|
+
"MAX_RECORDS": 1000,
|
|
67
|
+
"EXCLUDE_PATHS": [
|
|
68
|
+
"/static/",
|
|
69
|
+
"/favicon.ico",
|
|
70
|
+
"/.well-known/appspecific/com.chrome.devtools.json",
|
|
71
|
+
],
|
|
72
|
+
"TRUSTED_PROXY_CIDRS": [],
|
|
73
|
+
"INSPECTOR_ALLOWED_HOSTS": ["localhost", "127.0.0.1", "[::1]"],
|
|
74
|
+
"REPLAY_TIMEOUT": 10,
|
|
75
|
+
# Default: BASE_DIR / ".django-http-inspector.sqlite3"
|
|
76
|
+
"SQLITE_PATH": BASE_DIR / ".django-http-inspector.sqlite3",
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
django-http-inspector is disabled by default when `DEBUG=False`. The MVP Inspector UI is loopback-only.
|
|
81
|
+
|
|
82
|
+
To access Inspector from another device during development, use the single explicit switch and make Django listen on the network:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
DJANGO_HTTP_INSPECTOR = {
|
|
86
|
+
"ALLOW_REMOTE": True,
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
python manage.py runserver 0.0.0.0:8000
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Then open `http://<development-machine-ip>:8000/__inspect/`. Remote mode has no authentication: anyone who can connect can read captured credentials and bodies and trigger real Replay requests. Never expose it to the public internet or an untrusted network. The advanced `INSPECTOR_ALLOWED_HOSTS` and `INSPECTOR_ALLOWED_CLIENT_CIDRS` settings remain available for the default local-only mode but are not needed when `ALLOW_REMOTE=True`.
|
|
95
|
+
|
|
96
|
+
Inspector records live in a package-managed SQLite database, not in Django's business database. You do not need to add the package to `INSTALLED_APPS` or run migrations. The file survives `runserver` reloads; delete it to reset all Inspector history.
|
|
97
|
+
|
|
98
|
+
Add the runtime files to the project's `.gitignore`:
|
|
99
|
+
|
|
100
|
+
```gitignore
|
|
101
|
+
.django-http-inspector.sqlite3
|
|
102
|
+
.django-http-inspector.sqlite3-shm
|
|
103
|
+
.django-http-inspector.sqlite3-wal
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Replay semantics
|
|
107
|
+
|
|
108
|
+
Replay makes a real HTTP request to the complete URL reconstructed when the original request was captured. It does not substitute a loopback URL and does not call the Django handler in-process. Consequently, it can pass through DNS, TLS, a public tunnel, a gateway, the web server, and the complete Django middleware chain again.
|
|
109
|
+
|
|
110
|
+
WSGI servers normalize request data before applications see it. django-http-inspector therefore provides a semantically equivalent replay of the WSGI-observable request, not a byte-for-byte recreation of the network stream. Hop-by-hop headers are removed, `Host` and `Content-Length` are regenerated, and a correlation header is added.
|
|
111
|
+
|
|
112
|
+
Replay causes real side effects. Treat payment, email, webhook, and mutation endpoints accordingly.
|
|
113
|
+
|
|
114
|
+
The request stream refreshes automatically while the Inspector tab is visible. Select **Edit & Replay** to change a replay copy's headers and textual body. The captured method and complete URL remain read-only and are always used as the replay target; binary, multipart, and incomplete bodies cannot be edited. Duplicate headers are supported in the raw `Name: Value` editor.
|
|
115
|
+
|
|
116
|
+
Complete `multipart/form-data` bodies are presented as ordered form fields. File parts show only their filename, media type, and captured-content size; binary file bytes are never rendered. Malformed, incomplete, or oversized multipart previews safely fall back to the existing raw/binary view. This presentation does not modify the bytes saved or sent by Replay.
|
|
117
|
+
|
|
118
|
+
Chrome DevTools may request `/.well-known/appspecific/com.chrome.devtools.json` while inspecting localhost. The default exclusions prevent that harmless discovery request from cluttering Inspector, although Django may still log its 404 response.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python -m venv .venv
|
|
124
|
+
.venv/bin/python -m pip install -e ".[dev]"
|
|
125
|
+
.venv/bin/python tests/runtests.py
|
|
126
|
+
.venv/bin/python -m build
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
See the [approved design](docs/superpowers/specs/2026-09-09-django-http-inspector-mvp-design.md) for replay semantics and security boundaries.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# django-http-inspector
|
|
2
|
+
|
|
3
|
+
django-http-inspector is an embedded HTTP traffic inspector for Django development. It captures requests and responses outside the project's middleware chain and can replay a captured request to the URL seen at capture time over a real HTTP connection.
|
|
4
|
+
|
|
5
|
+
It is a development tool, not a reverse proxy, tunnel, production observability platform, or production security boundary.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m pip install django-http-inspector
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Intended integration
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
# wsgi.py
|
|
17
|
+
from django.core.wsgi import get_wsgi_application
|
|
18
|
+
from django_http_inspector import InspectorWSGI
|
|
19
|
+
|
|
20
|
+
application = InspectorWSGI(get_wsgi_application())
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python manage.py runserver
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Open `http://127.0.0.1:8000/__inspect/`.
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
DJANGO_HTTP_INSPECTOR = {
|
|
33
|
+
"ENABLED": DEBUG,
|
|
34
|
+
# Set True only on a trusted development network. No authentication is added.
|
|
35
|
+
"ALLOW_REMOTE": False,
|
|
36
|
+
"PATH": "/__inspect/",
|
|
37
|
+
"CAPTURE_MAX_BYTES": 1024 * 1024,
|
|
38
|
+
"MAX_RECORDS": 1000,
|
|
39
|
+
"EXCLUDE_PATHS": [
|
|
40
|
+
"/static/",
|
|
41
|
+
"/favicon.ico",
|
|
42
|
+
"/.well-known/appspecific/com.chrome.devtools.json",
|
|
43
|
+
],
|
|
44
|
+
"TRUSTED_PROXY_CIDRS": [],
|
|
45
|
+
"INSPECTOR_ALLOWED_HOSTS": ["localhost", "127.0.0.1", "[::1]"],
|
|
46
|
+
"REPLAY_TIMEOUT": 10,
|
|
47
|
+
# Default: BASE_DIR / ".django-http-inspector.sqlite3"
|
|
48
|
+
"SQLITE_PATH": BASE_DIR / ".django-http-inspector.sqlite3",
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
django-http-inspector is disabled by default when `DEBUG=False`. The MVP Inspector UI is loopback-only.
|
|
53
|
+
|
|
54
|
+
To access Inspector from another device during development, use the single explicit switch and make Django listen on the network:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
DJANGO_HTTP_INSPECTOR = {
|
|
58
|
+
"ALLOW_REMOTE": True,
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python manage.py runserver 0.0.0.0:8000
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then open `http://<development-machine-ip>:8000/__inspect/`. Remote mode has no authentication: anyone who can connect can read captured credentials and bodies and trigger real Replay requests. Never expose it to the public internet or an untrusted network. The advanced `INSPECTOR_ALLOWED_HOSTS` and `INSPECTOR_ALLOWED_CLIENT_CIDRS` settings remain available for the default local-only mode but are not needed when `ALLOW_REMOTE=True`.
|
|
67
|
+
|
|
68
|
+
Inspector records live in a package-managed SQLite database, not in Django's business database. You do not need to add the package to `INSTALLED_APPS` or run migrations. The file survives `runserver` reloads; delete it to reset all Inspector history.
|
|
69
|
+
|
|
70
|
+
Add the runtime files to the project's `.gitignore`:
|
|
71
|
+
|
|
72
|
+
```gitignore
|
|
73
|
+
.django-http-inspector.sqlite3
|
|
74
|
+
.django-http-inspector.sqlite3-shm
|
|
75
|
+
.django-http-inspector.sqlite3-wal
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Replay semantics
|
|
79
|
+
|
|
80
|
+
Replay makes a real HTTP request to the complete URL reconstructed when the original request was captured. It does not substitute a loopback URL and does not call the Django handler in-process. Consequently, it can pass through DNS, TLS, a public tunnel, a gateway, the web server, and the complete Django middleware chain again.
|
|
81
|
+
|
|
82
|
+
WSGI servers normalize request data before applications see it. django-http-inspector therefore provides a semantically equivalent replay of the WSGI-observable request, not a byte-for-byte recreation of the network stream. Hop-by-hop headers are removed, `Host` and `Content-Length` are regenerated, and a correlation header is added.
|
|
83
|
+
|
|
84
|
+
Replay causes real side effects. Treat payment, email, webhook, and mutation endpoints accordingly.
|
|
85
|
+
|
|
86
|
+
The request stream refreshes automatically while the Inspector tab is visible. Select **Edit & Replay** to change a replay copy's headers and textual body. The captured method and complete URL remain read-only and are always used as the replay target; binary, multipart, and incomplete bodies cannot be edited. Duplicate headers are supported in the raw `Name: Value` editor.
|
|
87
|
+
|
|
88
|
+
Complete `multipart/form-data` bodies are presented as ordered form fields. File parts show only their filename, media type, and captured-content size; binary file bytes are never rendered. Malformed, incomplete, or oversized multipart previews safely fall back to the existing raw/binary view. This presentation does not modify the bytes saved or sent by Replay.
|
|
89
|
+
|
|
90
|
+
Chrome DevTools may request `/.well-known/appspecific/com.chrome.devtools.json` while inspecting localhost. The default exclusions prevent that harmless discovery request from cluttering Inspector, although Django may still log its 404 response.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
python -m venv .venv
|
|
96
|
+
.venv/bin/python -m pip install -e ".[dev]"
|
|
97
|
+
.venv/bin/python tests/runtests.py
|
|
98
|
+
.venv/bin/python -m build
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See the [approved design](docs/superpowers/specs/2026-09-09-django-http-inspector-mvp-design.md) for replay semantics and security boundaries.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-http-inspector"
|
|
7
|
+
version = "0.1.4"
|
|
8
|
+
description = "An embedded HTTP traffic inspector and replay tool for Django development"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "django-http-inspector contributors"}]
|
|
14
|
+
keywords = ["django", "http", "webhook", "inspector", "replay", "debugging"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Web Environment",
|
|
18
|
+
"Framework :: Django",
|
|
19
|
+
"Framework :: Django :: 4.2",
|
|
20
|
+
"Framework :: Django :: 5.0",
|
|
21
|
+
"Framework :: Django :: 5.1",
|
|
22
|
+
"Framework :: Django :: 5.2",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Topic :: Software Development :: Debuggers",
|
|
28
|
+
]
|
|
29
|
+
dependencies = ["Django>=4.2"]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["build>=1.2", "twine>=5"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
package-dir = {"" = "src"}
|
|
36
|
+
include-package-data = true
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.package-data]
|
|
42
|
+
django_http_inspector = ["templates/**/*.html", "static/**/*"]
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from django.utils import timezone
|
|
5
|
+
|
|
6
|
+
from django_http_inspector.storage.records import ExchangeRecord
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("django_http_inspector")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExchangeCapture:
|
|
12
|
+
def __init__(self, environ, input_stream, config, url_data, headers, repository):
|
|
13
|
+
self.environ = environ
|
|
14
|
+
self.input_stream = input_stream
|
|
15
|
+
self.config = config
|
|
16
|
+
self.started = time.monotonic()
|
|
17
|
+
self.response_status = None
|
|
18
|
+
self.response_headers = []
|
|
19
|
+
self.response_body = bytearray()
|
|
20
|
+
self.response_size = 0
|
|
21
|
+
self.response_incomplete = False
|
|
22
|
+
self.error_summary = ""
|
|
23
|
+
self.finalized = False
|
|
24
|
+
self.exchange = None
|
|
25
|
+
self.repository = repository
|
|
26
|
+
url, scheme, host, provenance = url_data
|
|
27
|
+
if not repository.available:
|
|
28
|
+
return
|
|
29
|
+
nonce = next((value for name, value in headers if name.lower() == "x-django-http-inspector-replay"), None)
|
|
30
|
+
try:
|
|
31
|
+
self.exchange = repository.create_exchange(
|
|
32
|
+
correlation_nonce=nonce,
|
|
33
|
+
method=str(environ.get("REQUEST_METHOD", "GET")),
|
|
34
|
+
url=url,
|
|
35
|
+
url_provenance=provenance,
|
|
36
|
+
scheme=scheme,
|
|
37
|
+
host=host,
|
|
38
|
+
path=str(environ.get("PATH_INFO", "/")),
|
|
39
|
+
query_string=str(environ.get("QUERY_STRING", "")),
|
|
40
|
+
request_headers=headers,
|
|
41
|
+
request_content_type=str(environ.get("CONTENT_TYPE", "")),
|
|
42
|
+
request_declared_size=input_stream.declared_size,
|
|
43
|
+
client_addr=str(environ.get("REMOTE_ADDR", "")),
|
|
44
|
+
)
|
|
45
|
+
except Exception:
|
|
46
|
+
logger.exception("Unable to create django-http-inspector exchange")
|
|
47
|
+
|
|
48
|
+
def start(self, status, headers):
|
|
49
|
+
self.response_status = int(str(status).split(" ", 1)[0])
|
|
50
|
+
self.response_headers = [[str(name), str(value)] for name, value in headers]
|
|
51
|
+
|
|
52
|
+
def observe_response(self, data):
|
|
53
|
+
self.response_size += len(data)
|
|
54
|
+
remaining = self.config.capture_max_bytes - len(self.response_body)
|
|
55
|
+
if remaining > 0:
|
|
56
|
+
self.response_body.extend(data[:remaining])
|
|
57
|
+
|
|
58
|
+
def finalize(self, error=None, incomplete=False):
|
|
59
|
+
if not self.exchange or self.finalized:
|
|
60
|
+
return
|
|
61
|
+
self.finalized = True
|
|
62
|
+
now = timezone.now()
|
|
63
|
+
if error:
|
|
64
|
+
self.error_summary = f"{type(error).__name__}: {error}"[:2000]
|
|
65
|
+
try:
|
|
66
|
+
self.exchange.completed_at = now
|
|
67
|
+
self.exchange.duration_ms = (time.monotonic() - self.started) * 1000
|
|
68
|
+
self.exchange.request_body = bytes(self.input_stream.captured)
|
|
69
|
+
self.exchange.request_observed_size = self.input_stream.observed_size
|
|
70
|
+
self.exchange.request_captured_size = len(self.input_stream.captured)
|
|
71
|
+
self.exchange.request_body_truncated = self.input_stream.truncated
|
|
72
|
+
self.exchange.request_body_incomplete = self.input_stream.incomplete
|
|
73
|
+
self.exchange.response_status = self.response_status
|
|
74
|
+
self.exchange.response_headers = self.response_headers
|
|
75
|
+
self.exchange.response_body = bytes(self.response_body)
|
|
76
|
+
self.exchange.response_size = self.response_size
|
|
77
|
+
self.exchange.response_body_truncated = self.response_size > self.config.capture_max_bytes
|
|
78
|
+
self.exchange.response_body_incomplete = incomplete
|
|
79
|
+
self.exchange.state = ExchangeRecord.State.APPLICATION_ERROR if error else ExchangeRecord.State.COMPLETE
|
|
80
|
+
self.exchange.error_summary = self.error_summary
|
|
81
|
+
self.repository.update_exchange(self.exchange)
|
|
82
|
+
self.repository.prune(self.config.max_records)
|
|
83
|
+
except Exception:
|
|
84
|
+
logger.exception("Unable to finalize django-http-inspector exchange")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
HOP_BY_HOP_HEADERS = {
|
|
2
|
+
"connection",
|
|
3
|
+
"keep-alive",
|
|
4
|
+
"proxy-authenticate",
|
|
5
|
+
"proxy-authorization",
|
|
6
|
+
"te",
|
|
7
|
+
"trailer",
|
|
8
|
+
"transfer-encoding",
|
|
9
|
+
"upgrade",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def request_headers_from_environ(environ):
|
|
14
|
+
headers = []
|
|
15
|
+
if environ.get("CONTENT_TYPE"):
|
|
16
|
+
headers.append(["Content-Type", str(environ["CONTENT_TYPE"])])
|
|
17
|
+
if environ.get("CONTENT_LENGTH"):
|
|
18
|
+
headers.append(["Content-Length", str(environ["CONTENT_LENGTH"])])
|
|
19
|
+
for key, value in environ.items():
|
|
20
|
+
if key.startswith("HTTP_"):
|
|
21
|
+
name = "-".join(part.title() for part in key[5:].split("_"))
|
|
22
|
+
headers.append([name, str(value)])
|
|
23
|
+
return headers
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def replay_headers(headers):
|
|
27
|
+
result = []
|
|
28
|
+
for name, value in headers:
|
|
29
|
+
lower = name.lower()
|
|
30
|
+
if lower in HOP_BY_HOP_HEADERS or lower in {"content-length", "host", "x-django-http-inspector-replay"}:
|
|
31
|
+
continue
|
|
32
|
+
result.append((name, value))
|
|
33
|
+
return result
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from ipaddress import ip_address, ip_network
|
|
2
|
+
from urllib.parse import quote
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _trusted(remote_addr, cidrs):
|
|
6
|
+
try:
|
|
7
|
+
address = ip_address(remote_addr)
|
|
8
|
+
return any(address in ip_network(cidr, strict=False) for cidr in cidrs)
|
|
9
|
+
except ValueError:
|
|
10
|
+
return False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _forwarded(environ):
|
|
14
|
+
value = environ.get("HTTP_FORWARDED", "")
|
|
15
|
+
if not value:
|
|
16
|
+
return None, None
|
|
17
|
+
nearest = value.split(",")[-1]
|
|
18
|
+
parts = {}
|
|
19
|
+
for item in nearest.split(";"):
|
|
20
|
+
key, separator, raw = item.strip().partition("=")
|
|
21
|
+
if separator:
|
|
22
|
+
parts[key.lower()] = raw.strip().strip('"')
|
|
23
|
+
return parts.get("proto"), parts.get("host")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_url(environ, trusted_proxy_cidrs=()):
|
|
27
|
+
scheme = str(environ.get("wsgi.url_scheme", "http")).lower()
|
|
28
|
+
host = str(environ.get("HTTP_HOST") or "")
|
|
29
|
+
provenance = "reconstructed"
|
|
30
|
+
if _trusted(str(environ.get("REMOTE_ADDR", "")), trusted_proxy_cidrs):
|
|
31
|
+
forwarded_scheme, forwarded_host = _forwarded(environ)
|
|
32
|
+
scheme = forwarded_scheme or str(environ.get("HTTP_X_FORWARDED_PROTO", "")).split(",")[-1].strip() or scheme
|
|
33
|
+
host = forwarded_host or str(environ.get("HTTP_X_FORWARDED_HOST", "")).split(",")[-1].strip() or host
|
|
34
|
+
provenance = "trusted_proxy"
|
|
35
|
+
if scheme not in {"http", "https"} or not host or any(ch in host for ch in "\r\n/@"):
|
|
36
|
+
return "", scheme, host, provenance
|
|
37
|
+
|
|
38
|
+
raw_uri = environ.get("RAW_URI") or environ.get("REQUEST_URI")
|
|
39
|
+
if raw_uri:
|
|
40
|
+
target = str(raw_uri)
|
|
41
|
+
provenance = "server_specific"
|
|
42
|
+
if not target.startswith("/"):
|
|
43
|
+
target = "/" + target
|
|
44
|
+
else:
|
|
45
|
+
script = quote(str(environ.get("SCRIPT_NAME", "")), safe="/%:@")
|
|
46
|
+
path = quote(str(environ.get("PATH_INFO", "/")), safe="/%:@")
|
|
47
|
+
target = script + path
|
|
48
|
+
query = str(environ.get("QUERY_STRING", ""))
|
|
49
|
+
if query:
|
|
50
|
+
target += "?" + query
|
|
51
|
+
return f"{scheme}://{host}{target}", scheme, host, provenance
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from ipaddress import ip_network
|
|
3
|
+
from os import PathLike
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class InspectConfig:
|
|
13
|
+
enabled: bool
|
|
14
|
+
allow_remote: bool
|
|
15
|
+
path: str
|
|
16
|
+
capture_max_bytes: int
|
|
17
|
+
max_records: int
|
|
18
|
+
exclude_paths: Tuple[str, ...]
|
|
19
|
+
trusted_proxy_cidrs: Tuple[str, ...]
|
|
20
|
+
inspector_allowed_hosts: Tuple[str, ...]
|
|
21
|
+
inspector_allowed_client_cidrs: Tuple[str, ...]
|
|
22
|
+
replay_timeout: float
|
|
23
|
+
sqlite_path: Path
|
|
24
|
+
|
|
25
|
+
def is_inspector_path(self, path: str) -> bool:
|
|
26
|
+
base = self.path.rstrip("/")
|
|
27
|
+
return path == base or path.startswith(base + "/")
|
|
28
|
+
|
|
29
|
+
def is_excluded_path(self, path: str) -> bool:
|
|
30
|
+
return self.is_inspector_path(path) or any(
|
|
31
|
+
path == prefix.rstrip("/") or path.startswith(prefix)
|
|
32
|
+
for prefix in self.exclude_paths
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _path(value: object, name: str) -> str:
|
|
37
|
+
if not isinstance(value, str) or not value.startswith("/"):
|
|
38
|
+
raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must start with '/'.")
|
|
39
|
+
return value.rstrip("/") + "/"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _positive_number(value: object, name: str, number_type):
|
|
43
|
+
if isinstance(value, bool) or not isinstance(value, number_type) or value <= 0:
|
|
44
|
+
raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must be positive.")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _boolean(value: object, name: str) -> bool:
|
|
49
|
+
if type(value) is not bool:
|
|
50
|
+
raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must be a boolean.")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config() -> InspectConfig:
|
|
55
|
+
raw = getattr(settings, "DJANGO_HTTP_INSPECTOR", {})
|
|
56
|
+
if not isinstance(raw, dict):
|
|
57
|
+
raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR must be a dictionary.")
|
|
58
|
+
|
|
59
|
+
allow_remote = _boolean(raw.get("ALLOW_REMOTE", False), "ALLOW_REMOTE")
|
|
60
|
+
trusted = tuple(raw.get("TRUSTED_PROXY_CIDRS", ()))
|
|
61
|
+
clients = tuple(raw.get("INSPECTOR_ALLOWED_CLIENT_CIDRS", ("127.0.0.0/8", "::1/128")))
|
|
62
|
+
try:
|
|
63
|
+
for cidr in trusted + clients:
|
|
64
|
+
ip_network(cidr, strict=False)
|
|
65
|
+
except (TypeError, ValueError) as exc:
|
|
66
|
+
raise ImproperlyConfigured(f"Invalid django-http-inspector CIDR: {exc}") from exc
|
|
67
|
+
if not allow_remote and any(not ip_network(cidr, strict=False).is_loopback for cidr in clients):
|
|
68
|
+
raise ImproperlyConfigured(
|
|
69
|
+
"MVP Inspector access is loopback-only; non-loopback authentication is not implemented."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
hosts = tuple(raw.get("INSPECTOR_ALLOWED_HOSTS", ("localhost", "127.0.0.1", "[::1]")))
|
|
73
|
+
if not hosts or not all(isinstance(host, str) and host for host in hosts):
|
|
74
|
+
raise ImproperlyConfigured("INSPECTOR_ALLOWED_HOSTS must contain host names.")
|
|
75
|
+
|
|
76
|
+
base_dir = Path(getattr(settings, "BASE_DIR", Path.cwd()))
|
|
77
|
+
sqlite_value = raw.get("SQLITE_PATH", base_dir / ".django-http-inspector.sqlite3")
|
|
78
|
+
if not isinstance(sqlite_value, (str, PathLike)) or not str(sqlite_value):
|
|
79
|
+
raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR['SQLITE_PATH'] must be a non-empty path.")
|
|
80
|
+
sqlite_path = Path(sqlite_value)
|
|
81
|
+
if not sqlite_path.is_absolute():
|
|
82
|
+
sqlite_path = base_dir / sqlite_path
|
|
83
|
+
sqlite_path = sqlite_path.resolve()
|
|
84
|
+
if sqlite_path.exists() and sqlite_path.is_dir():
|
|
85
|
+
raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR['SQLITE_PATH'] must be a file, not a directory.")
|
|
86
|
+
|
|
87
|
+
return InspectConfig(
|
|
88
|
+
enabled=bool(raw.get("ENABLED", settings.DEBUG)),
|
|
89
|
+
allow_remote=allow_remote,
|
|
90
|
+
path=_path(raw.get("PATH", "/__inspect/"), "PATH"),
|
|
91
|
+
capture_max_bytes=_positive_number(raw.get("CAPTURE_MAX_BYTES", 1024 * 1024), "CAPTURE_MAX_BYTES", int),
|
|
92
|
+
max_records=_positive_number(raw.get("MAX_RECORDS", 1000), "MAX_RECORDS", int),
|
|
93
|
+
exclude_paths=tuple(_path(p, "EXCLUDE_PATHS") for p in raw.get(
|
|
94
|
+
"EXCLUDE_PATHS",
|
|
95
|
+
("/static/", "/favicon.ico", "/.well-known/appspecific/com.chrome.devtools.json"),
|
|
96
|
+
)),
|
|
97
|
+
trusted_proxy_cidrs=trusted,
|
|
98
|
+
inspector_allowed_hosts=hosts,
|
|
99
|
+
inspector_allowed_client_cidrs=clients,
|
|
100
|
+
replay_timeout=float(_positive_number(raw.get("REPLAY_TIMEOUT", 10), "REPLAY_TIMEOUT", (int, float))),
|
|
101
|
+
sqlite_path=sqlite_path,
|
|
102
|
+
)
|
|
File without changes
|