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.
Files changed (52) hide show
  1. django_http_inspector-0.1.4/LICENSE +21 -0
  2. django_http_inspector-0.1.4/PKG-INFO +129 -0
  3. django_http_inspector-0.1.4/README.md +101 -0
  4. django_http_inspector-0.1.4/pyproject.toml +42 -0
  5. django_http_inspector-0.1.4/setup.cfg +4 -0
  6. django_http_inspector-0.1.4/src/django_http_inspector/__init__.py +6 -0
  7. django_http_inspector-0.1.4/src/django_http_inspector/capture/__init__.py +0 -0
  8. django_http_inspector-0.1.4/src/django_http_inspector/capture/exchange.py +84 -0
  9. django_http_inspector-0.1.4/src/django_http_inspector/capture/headers.py +33 -0
  10. django_http_inspector-0.1.4/src/django_http_inspector/capture/url.py +51 -0
  11. django_http_inspector-0.1.4/src/django_http_inspector/config.py +102 -0
  12. django_http_inspector-0.1.4/src/django_http_inspector/inspector/__init__.py +0 -0
  13. django_http_inspector-0.1.4/src/django_http_inspector/inspector/app.py +292 -0
  14. django_http_inspector-0.1.4/src/django_http_inspector/inspector/presentation.py +195 -0
  15. django_http_inspector-0.1.4/src/django_http_inspector/inspector/security.py +45 -0
  16. django_http_inspector-0.1.4/src/django_http_inspector/inspector/templates.py +21 -0
  17. django_http_inspector-0.1.4/src/django_http_inspector/replay/__init__.py +0 -0
  18. django_http_inspector-0.1.4/src/django_http_inspector/replay/edit.py +119 -0
  19. django_http_inspector-0.1.4/src/django_http_inspector/replay/service.py +69 -0
  20. django_http_inspector-0.1.4/src/django_http_inspector/replay/target.py +68 -0
  21. django_http_inspector-0.1.4/src/django_http_inspector/replay/transport.py +85 -0
  22. django_http_inspector-0.1.4/src/django_http_inspector/static/django_http_inspector/inspect.css +139 -0
  23. django_http_inspector-0.1.4/src/django_http_inspector/static/django_http_inspector/inspect.js +210 -0
  24. django_http_inspector-0.1.4/src/django_http_inspector/storage/__init__.py +3 -0
  25. django_http_inspector-0.1.4/src/django_http_inspector/storage/records.py +84 -0
  26. django_http_inspector-0.1.4/src/django_http_inspector/storage/repository.py +276 -0
  27. django_http_inspector-0.1.4/src/django_http_inspector/storage/schema.py +40 -0
  28. django_http_inspector-0.1.4/src/django_http_inspector/templates/django_http_inspector/base.html +13 -0
  29. django_http_inspector-0.1.4/src/django_http_inspector/templates/django_http_inspector/index.html +101 -0
  30. django_http_inspector-0.1.4/src/django_http_inspector/wrapper/__init__.py +0 -0
  31. django_http_inspector-0.1.4/src/django_http_inspector/wrapper/input.py +68 -0
  32. django_http_inspector-0.1.4/src/django_http_inspector/wrapper/response.py +38 -0
  33. django_http_inspector-0.1.4/src/django_http_inspector/wrapper/wsgi.py +78 -0
  34. django_http_inspector-0.1.4/src/django_http_inspector.egg-info/PKG-INFO +129 -0
  35. django_http_inspector-0.1.4/src/django_http_inspector.egg-info/SOURCES.txt +50 -0
  36. django_http_inspector-0.1.4/src/django_http_inspector.egg-info/dependency_links.txt +1 -0
  37. django_http_inspector-0.1.4/src/django_http_inspector.egg-info/requires.txt +5 -0
  38. django_http_inspector-0.1.4/src/django_http_inspector.egg-info/top_level.txt +1 -0
  39. django_http_inspector-0.1.4/tests/test_config.py +75 -0
  40. django_http_inspector-0.1.4/tests/test_end_to_end.py +100 -0
  41. django_http_inspector-0.1.4/tests/test_input_capture.py +47 -0
  42. django_http_inspector-0.1.4/tests/test_inspector_security.py +23 -0
  43. django_http_inspector-0.1.4/tests/test_inspector_ui.py +187 -0
  44. django_http_inspector-0.1.4/tests/test_models.py +95 -0
  45. django_http_inspector-0.1.4/tests/test_presentation.py +100 -0
  46. django_http_inspector-0.1.4/tests/test_replay_edit.py +72 -0
  47. django_http_inspector-0.1.4/tests/test_replay_service.py +99 -0
  48. django_http_inspector-0.1.4/tests/test_replay_target.py +18 -0
  49. django_http_inspector-0.1.4/tests/test_replay_transport.py +45 -0
  50. django_http_inspector-0.1.4/tests/test_response_capture.py +37 -0
  51. django_http_inspector-0.1.4/tests/test_url_capture.py +22 -0
  52. 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/**/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Public API for django-http-inspector."""
2
+
3
+ from .wrapper.wsgi import InspectorWSGI
4
+
5
+ __all__ = ["InspectorWSGI"]
6
+ __version__ = "0.1.4"
@@ -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
+ )